tdd-the-hard-parts



tdd-the-hard-parts

0 1


tdd-the-hard-parts

Presentation on overcoming difficulties in front-end testing. Live Slides:

On Github NickTomlin / tdd-the-hard-parts

TDD: The Hard Parts

๐Ÿ˜ฟ

Slides: http://bit.ly/1L9bckF

Feedback: http://midwestjs.com/feedback

Hi

  • JavaScript @ Braintree
  • Lots of ๐Ÿ’š for ๐Ÿœ
  • Poetry!

(braintreepayments.com/careers)

if any of those things sound interesting to you, talk to me afterwards.

A hypothetical start

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 shiznit

We 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]

Jeff Atwood

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
โ”›โ”—โ”›โ”—โ”›โ”ƒ
โ”“โ”โ”“โ”โ”“โ”ƒ
โ”›โ”—โ”›โ”—โ”›โ”ƒ
โ”“โ”โ”“โ”โ”“โ”ƒ
โ”ƒโ”ƒโ”ƒโ”ƒโ”ƒโ”ƒ
โ”ปโ”ปโ”ปโ”ปโ”ปโ”ป

@davidwalsh

from http://musichistoryingifs.com/

2014 JS Daily Survey

Test-first fundamentalism is like abstinence-only sex ed: An unrealistic, ineffective morality campaign for self-loathing and shaming.

David Heinemeier Hansson

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.

A little history

Kent Beck claims that he was inspired by an early book that encouraged programmers to write a piece of tape with an expectation and then compare the output of their program to that original piece of tape. Regardless, the need for feedback has been recognized by past programmers. Image from Museum Victoria http://museumvictoria.com.au/collections/itemimages/205/679/205679_large.jpg

<gross-oversimplification>

TDD is about:

  • Feedback
  • Ownership
  • Empowerment

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

Roadblocks to TDD

๐Ÿ˜ฟ 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

  • Code should be covered by tests whenever possible.
  • Tests should clearly describe the feature they cover.
  • Someone should be able to help you write better tests.

๐Ÿ˜ธ 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 be

These 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.

Building

๐Ÿ˜ฟ 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

there are a few different definitions for integration. I'll choose to include "view" tests (albeit ones run headlessly) as "unit" tests and selenium driven tests in the "integration" category. You will probably disagree with me. There are plenty of automated tools out there to help you do this if you need.

confidence is made up of all testing layers

coverage is a means of gauging your confidence, but it isn't an exact measure.

Things are always different out in the wild. Perfect coverage !== flawless application.

๐Ÿ˜ธ 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

Maintaining tests

we don't always think of tests like we do production code. The environment is different, but many of the rules apply.

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 todo
Talk 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

The joys of integration testing

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

SUCCESS
we 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'))

The long road

Tdd is an art.

We can make art.

Resources

Thanks

  • @itsnicktomlin
  • github.com/nicktomlin

Slides: http://bit.ly/1L9bckF

Feedback: http://midwestjs.com/feedback

TDD: The Hard Parts ๐Ÿ˜ฟ Slides: http://bit.ly/1L9bckF Feedback: http://midwestjs.com/feedback