On Github NickTomlin / tdd-the-hard-parts
๐ฟ
Slides: http://bit.ly/1L9bckF
Feedback: http://midwestjs.com/feedback
You may be familiar with...
LGTM ๐ข๐ฎ๐น
class TodoApp () { constructor () { this.todos = []; this.input = document.querySelector('#new-todo'); this.todoList = document.querySelector('#new-todo'); this.attachEvents(); this.render(); } render () { /* ... */ } attachEvents () { /* ... */ } } let app = new TodoApp();
๐ฑ
animate this shiznitWe know this is a bad idea.
But why?
The general adoption of unit testing is one of the most fundamental advances in software development in the last 5 to 7 years. - [20 Jul 2006]
We've got a very healthy culture of advocating for testing. Most of us here (whether we do it all the time or not) would say that automated testing is a good thingโโโโโโ โโโโโโ๏ผผโ๏ผ โโโโโโ / No unit โโโโโโใ) โโโโโโ tests โโโโโโ โโโโโโ โโโโโโ โโโโโโ โโโโโโ โปโปโปโปโปโป
Test-first fundamentalism is like abstinence-only sex ed: An unrealistic, ineffective morality campaign for self-loathing and shaming.
We have a culture that compulsively advocates unit testing (for good reason) but does not necessarily give us the background on the purpose behind coverage. Like a lot of other movements within software development (agile, semantic html, etc) the benefit of adoption can get lost in the noise around standardizing and implementation.<gross-oversimplification>
TDD is about:
Tests are a means to an end
</gross-oversimplification>
this is a holistic thing. You're supposed to feel better about writing new code and maintaining existing code. No one should hang you out to dry for not having perfect coverage or an immense testing suite if you feel comfortable maintaining your code base (and passing it on to others)TDD should be guilt free
100% coverage does not mean you are a better person or developer. WE MAY WANT TO INCLUDE SOME BDD information here๐ฟ Not Enough Time
๐ธ Don't quote tests
PMs are rarely going to put "write tests" on a roadmap. Tests are part of a feature, not a feature in themselves. Pad your estimates with time for testing built in. That means you don't have to feel awful if you bang your head against a wall trying to test a feature. Tests aren't extra work, they are the work.๐ธ Be pragmatic
you don't always need 100% or even 80% coverage. Maximize what sort of things you are testing if you are really on a time crunch. We'll get into this more later.๐ฟ No Culture
it's very difficult to work in a project where there is little buy-in for testing.๐ธ Create a Test Positive Culture
๐ธ Find a better culture (there are many)
๐ฟ Building testing infrastructure is hard
There's a lot of investment up front. I may take a week or two (or more) to figure out and implement a solid testing infrastructure. (would be good if we could have an image to )๐ธ Get help (from a real person)
IRC; your local meetup; a more knowledable coworker. Someone has solved your problem before. Make sure you use all the available resources (and make yourself available as well!). Being a front-end developer means that you may not love digging around in the terminal configuring things all day. That's totally okay, just find someone who can get you over the hump.๐ธ Don't roll your own (unless you have to)
If you are manually running your tests/setting up scaffolding you probably shouldn't beThese pluggable frameworks will helper you launch browsers, pre-process (and more!)
๐ธ Invest Up Front
Braintree gives devs every other Friday to work on a technical project. I've used this time to help build some testing infrastructure up. The extra time is worth it to make your everyday better.๐ฟ Front-End Iteration
even with an application as simple as the one we are building, it can be hard to get started if you are used to iterating on the front-end. I often develop "in browser" and that poking around with a layout/interface is hard to test. Getting started can be the hardest part. (A Video here of someone playing around with a browser setting)It's hard to TDD "feel"
it('pops')I often find myself discarding the test driven workflow at the start of a project when I am iterating on a UI. Because we are in "mid-stack" we are often faced with shifting apis or design requirements that force us to be light on our feet.
Pairing Pressure
๐ธ The Invaluable Spike
A spike helps us respond changes and iterate without needing to leave the TDD workflow.๐ธ Outside in: feature first
this is more BDD style (but often the two are conflated)// tests/integration/todo-application.test.js describe('TodoApplication', () => { it('allows a user to add todos', () => { browser.get('/'); $('#new-todo') .sendKeys('Test') .sendKeys(protractor.Key.ENTER); let todos = $$('#todo-list li'); expect(todos.get(0).getText()).toContain('Test'); }); });
๐ฟ Knowing what to test
confidence is made up of all testing layers
coverage is a means of gauging your confidence, but it isn't an exact measure.๐ธ Invest in the most effective layer
some things are more more complex to test at certain layers. Drag and drop operations may not be cost effective to test in a headless browser environment and would be better served at the integration level (through selenium). "Native" features might be better pushed to manual testing if the infrastructure required to test them is too much. It's a constant tradeoff.Cost/Reward: Component Integration Test
describe('PhantomJS View Test', () => { it('adds a todo when the user presses enter', () => { let view = new TodoView(); let input = document.querySelector('#new-todo'); input.value = 'Add a headless test'; let event = document.createEvent('Event'); event.keyCode = 13; event.init('keyup'); input.dispatchEvent(event); expect(view.counter).to.eql(1); }); });these tests are nice because they area faster than full-on selenium tests, but they aren't as real as your integration tests. You may be relying on
๐ฑ not testing things๐ฑ
i'll sometimes just have basic integration tests for a project that let me know if i've broken something or not. Or i'll have simple manual instructions. As long as there is something that allows you to commit with a level of confidence (given the context of your code) that is a good enough. That is, however, not acceptable for handling payments ;) I've actually found it difficult to get out of the testing mindset at times (I really like TDD)๐ฟ Warped Code
import xhr from 'superagent'; import RSVP from 'rsvp'; function getTodoStatus(id) { let deferred = RSVP.defer(); xhr .get(`/api/${id}`) .end((err, result) => { _handleXhr(deferred, err, result); }); return deferred.promise; } function _handleXhr(deferred, err, result) { if (err) { return deferred.reject('Error'); } deferred.resolve(result.body); }
describe('getTodoStatus', () => { describe('_handleXhr', () => { it('rejects if err is defined', () => { let mockDeferred = {reject: sinon.spy()}; let error = new Error(); _handleXhr(error, mockDeferred); expect(mockDeferred.reject).to.have.been.calledWith('Error'); }); }); });
๐ธ Prefer input control to _isolation
describe('getTodoStatus', () => { it('resolves with an error on connection error', (done) => { let fakeServer = sinon.fakeServer.create(); fakeServer.respondWith('GET', [500, {}, '']); getTodoStatus(1) .catch((result) => { expect(result).to.eql('Error'); done(); }); }); });
You and your code benefit
import xhr from 'superagent'; function getTodoStatus(id) { return new Promise((resolve, reject) => { xhr .get(`/api/${id}`) .end((err, result) => { if (err) { return reject('Error'); } resolve(result.body); }); }) }
๐ฟ Testing dependencies
import externalApi from 'external-api'; class MyService { get () { return externalApi.request(); } }
Breaking things up for testability
import externalApi from 'external-api'; class MyService { get () { return _externalApiProxy() }, _externalApiProxy () { return externalApi.request(); } }
describe('get', () => { it('calls _externalApiProxy') });
import proxyquire from 'proxyquire'; describe('myService', () => { it('fetches a result from externalApi', () => { let fakeExternalApi = {request: sinon.spy()}; let myService = new proxyQuire('MyService', { 'external-api' fakeExternalApi }); myService.get(); expect(fakeExternalApi.request).to.have.been.called; }); });
Tools for Dependency substitution
Tests are first class citizens
Tests tell a story
Todos #completed returns completed todos returns an empty array if there are no results #update changes the text of a todo adds tags contained in the text of a todo updates the edited timestamp of a todoTalk about tests as documentation. show blade's describe output. Say "it's to easy to just check tests off as having been written. I do it all the time :("
๐ฟ Tests are boring to read
make sure your coworkers are copying good tests when possible.๐ธ Obliterate Meaningless tests
describe('Todos', () => { describe('get', () => { // meaningless drivel it('has a get method', () => { let todos = new Todos(); expect(todos).to.respondTo('get'); }); // this actually covers the method it('retrieves todos', () => { let todos = new Todos(); expect(todos.get()).to.eql(1); }); }); });bad tests sneak into the codebase, clutter our understanding, and should be removed as soon as possible. It's too easy to have tests stick around forever.
๐ธ Reduce non-descriptive boilerplate.
it('retrieves todos', () => { let todos = new Todos(); todos.configure({admin: true}) todos.add({ text: 'test1', user: 'Cheryl' }); expect(todos.get(1).text).to.eql('test1'); }); it('deletes todos', () => { let todos = new Todos(); todos.configure({admin: true}) todos.add({ text: 'test2', user: 'James' }); todos.delete(1); expect(todos.get(1)).to.eql(undefined); });
let todos = null; beforeEach(() => { todos = new Todos(); todos.configure({admin: true}); }); it('retrieves todos', () => { todos.add({ text: 'test1', user: 'Cheryl' }); expect(todos.get(1).text).to.eql('test1'); }); it('deletes todos', () => { todos.add({ text: 'test2', user: 'James' }); // ...this needs to be a: combination of both useful and descriptive setup + non-useful boilerplate
Don't be too DRY
describe('edge case', function () { strange(); setup(); code(); let state = todos.completed(); expect(state).to.match('completed'); });
๐ฟ Tests are boring to write
๐ธ Keep your tests fresh
let spy = sinon.spy(); expect(spy.calledWith(1)).to.eql(true);
import sinonChai from 'sinon-chai'; chai.use(sinonChai);
expect(spy).to.have.been.calledWith(1);
Tests should be as fun as possible to write
Integration Tests === ๐ธ ๐ธ ๐ธ
Integration Tests === ๐ฟ ๐ฟ ๐ฟ
๐ฟ Flakey tests
๐ธ Invest in infrastructure
๐ธ Be prepared for flakiness
we are using rudimentary retries for our integration tests. This is a boon in my current project where we are downstream from 5 applications in a complex environment. It has taken some of the sting out of CI runs. Props to @walmartlabs for the suggestion in their JSConfg talk.npm run test:integration FAILED re-running tests: test attempt 1 FAILED re-running tests: test attempt 2 FAILED re-running tests: test attempt 2 SUCCESSwe have a re-runner in place for our application. It's rudimentary but very effective
๐ฟ Slow Tests
๐ธ Shard
๐ธ Sleep Smart
// not so good wait(2000); // better waitFor($('#todos').isPresent);
๐ธ Fail as fast and as explicitly as possible.
helpers.addTodo('test') // lengthy animated process expect($('#todos').get(0).getText().to.equal('test');
expect($('#new-todo').isPresent).to.be.true; helpers.addTodo('test'); // lengthy animated process expect($('#todos').get(0).getText().to.equal('test');
๐ฟ Selenium quirks
$('#complete-todo').click(); browser .actions() .mouseDown() .mouseUp() .perform();
๐ธ Abstract (some of) the pain away
export function selectItem (elem) { elem.click(); browser .actions() .mouseDown() .mouseUp() .perform(); }
import {selectItem} from './page-helpers'; selectItem($('#complete-todo'))
Tdd is an art.
We can make art.
Resources
Slides: http://bit.ly/1L9bckF
Feedback: http://midwestjs.com/feedback