Как не надо тестировать

Иван Стрелков

Как не надо тестировать

Иван Стрелков

Тестирование

Модульное тестирование фронтенда

Путь тестирования

Шаг 0. Вызов

Шаг 1. Начало

Шаг 2. Принятие

Шаг 3. Test all the things!

Шаг 4. Боль

Шаг 4. Боль

Вредные тесты

О чем доклад?

Антипаттерны тестирования:
  1. Тестирование статики
  2. Тесты-близнецы
  3. Слишком сложный тест:
    • Верстка и сеть
    • Асинхронный код

Тестирование статики

Тестирование статики - Тест

      
describe('VideoView', () => {
  it('is editable', () => {
    expect(new VideoView().editable).toBe(true);
  });
});
      
    

Тестирование статики - Код

      
class VideoView extends GenericView {
  editable = true;

  // ...
}
      
    
Тестируются статические данные

Тестирование статики - Причины

Тестирование статики - Правильный тест

Тестирование статики - Правильный тест


describe('GenericView', () => {
  it('disallows editing by default', () => {
    var view = new GenericView();
    expect(view.$el.find('.edit').length).toBe(0);
  });

  it('allows editing via specifying "editable" flag', () => {
    var view = new GenericView({ editable: true });
    expect(view.$el.find('.edit').length).toBe(1);
  });
});
    

Применим в случае если editable - документированный флаг

Тестирование статики - Правильный тест


describe('GenericView', () => {
  it('disallows editing by default', () => {
    var view = new GenericView();
    expect(view.$el.find('.edit').length).toBe(0);
  });

  it('allows editing via specifying "editable" flag', () => {
    var view = new GenericView({ editable: true });
    expect(view.$el.find('.edit').length).toBe(1);
  });
});
    

Применим в случае если editable - документированный флаг

Тестирование статики - Правильный тест


describe('GenericView', () => {
  it('disallows editing by default', () => {
    var view = new GenericView();
    expect(view.$el.find('.edit').length).toBe(0);
  });

  it('allows editing via specifying "editable" flag', () => {
    var view = new GenericView({ editable: true });
    expect(view.$el.find('.edit').length).toBe(1);
  });
});
    

Применим в случае если editable - документированный флаг

Тестирование статики - Правильный тест


describe('GenericView', () => {
  it('disallows editing by default', () => {
    var view = new GenericView();
    expect(view.$el.find('.edit').length).toBe(0);
  });

  it('allows editing via specifying "editable" flag', () => {
    var view = new GenericView({ editable: true });
    expect(view.$el.find('.edit').length).toBe(1);
  });
});
    

Применим в случае если editable - документированный флаг

Тестирование статики - Правильный тест


describe('GenericView', () => {
  it('disallows editing by default', () => {
    var view = new GenericView();
    expect(view.$el.find('.edit').length).toBe(0);
  });

  it('allows editing via specifying "editable" flag', () => {
    var view = new GenericView({ editable: true });
    expect(view.$el.find('.edit').length).toBe(1);
  });
});
    

Применим в случае если editable - документированный флаг

Тестирование статики - Правильный тест


describe('VideoView', () => {
  describe('editing', () => {
    beforeEach(() => {
      this.view = new VideoView();
      view.$el.find('.edit').click();
    });
    it('opens editing popup', () => {
      expect(this.view.$el.find('.editing-popup').length).toBe(1);
    });
    it('saves on editing', () => {
      this.view.$el.find('.title').val('new value').change();
      this.view.$el.find('.save').click();
      expect(this.view.model.get('title')).toBe('new value');
    });
});
     

Тестирование статики - Правильный тест


describe('VideoView', () => {
  describe('editing', () => {
    beforeEach(() => {
      this.view = new VideoView();
      view.$el.find('.edit').click();
    });
    it('opens editing popup', () => {
      expect(this.view.$el.find('.editing-popup').length).toBe(1);
    });
    it('saves on editing', () => {
      this.view.$el.find('.title').val('new value').change();
      this.view.$el.find('.save').click();
      expect(this.view.model.get('title')).toBe('new value');
    });
});
     

Тестирование статики - Правильный тест


describe('VideoView', () => {
  describe('editing', () => {
    beforeEach(() => {
      this.view = new VideoView();
      view.$el.find('.edit').click();
    });
    it('opens editing popup', () => {
      expect(this.view.$el.find('.editing-popup').length).toBe(1);
    });
    it('saves on editing', () => {
      this.view.$el.find('.title').val('new value').change();
      this.view.$el.find('.save').click();
      expect(this.view.model.get('title')).toBe('new value');
    });
});
     

Тестирование статики - Правильный тест


describe('VideoView', () => {
  describe('editing', () => {
    beforeEach(() => {
      this.view = new VideoView();
      view.$el.find('.edit').click();
    });
    it('opens editing popup', () => {
      expect(this.view.$el.find('.editing-popup').length).toBe(1);
    });
    it('saves on editing', () => {
      this.view.$el.find('.title').val('new value').change();
      this.view.$el.find('.save').click();
      expect(this.view.model.get('title')).toBe('new value');
    });
});
     

Тестирование статики - Правильный тест


describe('VideoView', () => {
  describe('editing', () => {
    beforeEach(() => {
      this.view = new VideoView();
      view.$el.find('.edit').click();
    });
    it('opens editing popup', () => {
      expect(this.view.$el.find('.editing-popup').length).toBe(1);
    });
    it('saves on editing', () => {
      this.view.$el.find('.title').val('new value').change();
      this.view.$el.find('.save').click();
      expect(this.view.model.get('title')).toBe('new value');
    });
});
     

Тестирование статики - Правильный тест


describe('VideoView', () => {
  describe('editing', () => {
    beforeEach(() => {
      this.view = new VideoView();
      view.$el.find('.edit').click();
    });
    it('opens editing popup', () => {
      expect(this.view.$el.find('.editing-popup').length).toBe(1);
    });
    it('saves on editing', () => {
      this.view.$el.find('.title').val('new value').change();
      this.view.$el.find('.save').click();
      expect(this.view.model.get('title')).toBe('new value');
    });
});
     

Тестирование статики - Правильный тест


describe('VideoView', () => {
  describe('editing', () => {
    beforeEach(() => {
      this.view = new VideoView();
      view.$el.find('.edit').click();
    });
    it('opens editing popup', () => {
      expect(this.view.$el.find('.editing-popup').length).toBe(1);
    });
    it('saves on editing', () => {
      this.view.$el.find('.title').val('new value').change();
      this.view.$el.find('.save').click();
      expect(this.view.model.get('title')).toBe('new value');
    });
});
     

Тестирование статики - Опасность

Тест-близнец

a.k.a. Mockery

Тест-близнец - Stub/Mock

Стаб или мок - метод, который заменяет настоящий метод и при этом следит за тем, как его вызывают.

it('performs ajax call to /rest/images', () => {
  sinon.stub(jQuery, 'ajax');

  fetchImages();

  assert(jQuery.ajax.calledWith('/rest/images'));
});
    

Тест-близнец - Код


module.exports = {
  build() {
    firstLib.clean('build')
      .then(() => secondLib.compileJS('src', 'build'))
      .then(() => thirdLib.optimizeCSS())
  }
};
    

Тест-близнец - Код


module.exports = {
  build() {
    firstLib.clean('build')                               // очистка
      .then(() => secondLib.compileJS('src', 'build'))
      .then(() => thirdLib.optimizeCSS());
  }
};
    

Тест-близнец - Код


module.exports = {
  build() {
    firstLib.clean('build')
      .then(() => secondLib.compileJS('src', 'build'))    // JS
      .then(() => thirdLib.optimizeCSS());
  }
};
    

Тест-близнец - Код


module.exports = {
  build() {
    firstLib.clean('build')
      .then(() => secondLib.compileJS('src', 'build'))
      .then(() => thirdLib.optimizeCSS());                // CSS
  }
};
    

Тест-близнец - Тест


describe('build step', () => {
  beforeEach(() => {
    // Подменяем библиотечные методы моками с resolved promises
    buildSystem.build();
  });

  it('builds, compiles and does something else', () => {
    assert(firstLib.clean.calledWith('build'));

    assert(secondLib.compileJS.calledWith('src', 'build'));

    assert(thirdLib.optimizeCSS.called);
  });
});
    

Тест-близнец - Тест


describe('build step', () => {
  beforeEach(() => {
    // Подменяем библиотечные методы моками с resolved promises
    buildSystem.build();
  });

  it('builds, compiles and does something else', () => {
    assert(firstLib.clean.calledWith('build'));

    assert(secondLib.compileJS.calledWith('src', 'build'));

    assert(thirdLib.optimizeCSS.called);
  });
});
    

Тест-близнец - Тест


describe('build step', () => {
  beforeEach(() => {
    // Подменяем библиотечные методы моками с resolved promises
    buildSystem.build();
  });

  it('builds, compiles and does something else', () => {
    assert(firstLib.clean.calledWith('build'));

    assert(secondLib.compileJS.calledWith('src', 'build'));

    assert(thirdLib.optimizeCSS.called);
  });
});
    

Тест-близнец - Тест


describe('build step', () => {
  beforeEach(() => {
    // Подменяем библиотечные методы моками с resolved promises
    buildSystem.build();
  });

  it('builds, compiles and does something else', () => {
    assert(firstLib.clean.calledWith('build'));             // очистка

    assert(secondLib.compileJS.calledWith('src', 'build'));

    assert(thirdLib.optimizeCSS.called);
  });
});
    

Тест-близнец - Тест


describe('build step', () => {
  beforeEach(() => {
    // Подменяем библиотечные методы моками с resolved promises
    buildSystem.build();
  });

  it('builds, compiles and does something else', () => {
    assert(firstLib.clean.calledWith('build'));

    assert(secondLib.compileJS.calledWith('src', 'build')); // JS

    assert(thirdLib.optimizeCSS.called);
  });
});
    

Тест-близнец - Тест


describe('build step', () => {
  beforeEach(() => {
    // Подменяем библиотечные методы моками с resolved promises
    buildSystem.build();
  });

  it('builds, compiles and does something else', () => {
    assert(firstLib.clean.calledWith('build'));

    assert(secondLib.compileJS.calledWith('src', 'build'));

    assert(thirdLib.optimizeCSS.called);                    // CSS
  });
});
    

Декларативный тест-близнец - Код


class HomePage extends React.Component {
  render() {
    return (
      <div>
        <Header userName={this.props.userName} />
        <Content> {this.props.invitation} </Content>
        <Footer showSocial />
      </div>
    )
  }
}
    

Декларативный тест-близнец - Код


class HomePage extends React.Component {
  render() {
    return (
      <div>
        <Header userName={this.props.userName} />
        <Content> {this.props.invitation} </Content>
        <Footer showSocial />
      </div>
    )
  }
}
    

Декларативный тест-близнец - Код


class HomePage extends React.Component {
  render() {
    return (
      <div>
        <Header userName={this.props.userName} />
        <Content> {this.props.invitation} </Content>
        <Footer showSocial />
      </div>
    )
  }
}
    

Декларативный тест-близнец - Код


class HomePage extends React.Component {
  render() {
    return (
      <div>
        <Header userName={this.props.userName} />
        <Content> {this.props.invitation} </Content>
        <Footer showSocial />
      </div>
    )
  }
}
    

Декларативный тест-близнец - Тест


describe('HomePage', () => {
  beforeEach(() => { /* <HomePage userName="vasya" invitation="hello"/> */ });
  it('renders header with userName', () => {
    let header = TestUtils.findRenderedComponent(this.homePage, Header);
    expect(header.props.userName).toBe('vasya');
  });
  it('renders footer', () => {
    let footer = TestUtils.findRenderedComponent(this.homePage, Footer);
    expect(footer.props.showSocial).toBe(true);
  });
  it('renders invitation', () => {
    expect(this.container.innerHTML).toContain('hello');
  });
});
    

Декларативный тест-близнец - Тест


describe('HomePage', () => {
  beforeEach(() => { /* <HomePage userName="vasya" invitation="hello"/> */ });
  it('renders header with userName', () => {
    let header = TestUtils.findRenderedComponent(this.homePage, Header);
    expect(header.props.userName).toBe('vasya');
  });
  it('renders footer', () => {
    let footer = TestUtils.findRenderedComponent(this.homePage, Footer);
    expect(footer.props.showSocial).toBe(true);
  });
  it('renders invitation', () => {
    expect(this.container.innerHTML).toContain('hello');
  });
});
    

Декларативный тест-близнец - Тест


describe('HomePage', () => {
  beforeEach(() => { /* <HomePage userName="vasya" invitation="hello"/> */ });
  it('renders header with userName', () => {
    let header = TestUtils.findRenderedComponent(this.homePage, Header);
    expect(header.props.userName).toBe('vasya');
  });
  it('renders footer', () => {
    let footer = TestUtils.findRenderedComponent(this.homePage, Footer);
    expect(footer.props.showSocial).toBe(true);
  });
  it('renders invitation', () => {
    expect(this.container.innerHTML).toContain('hello');
  });
});
    

Декларативный тест-близнец - Тест


describe('HomePage', () => {
  beforeEach(() => { /* <HomePage userName="vasya" invitation="hello"/> */ });
  it('renders header with userName', () => {
    let header = TestUtils.findRenderedComponent(this.homePage, Header);
    expect(header.props.userName).toBe('vasya');
  });
  it('renders footer', () => {
    let footer = TestUtils.findRenderedComponent(this.homePage, Footer);
    expect(footer.props.showSocial).toBe(true);
  });
  it('renders invitation', () => {
    expect(this.container.innerHTML).toContain('hello');
  });
});
    

Тест-близнец - Причины

Тест-близнец - Правильный тест

Протестировать наличие в компоненте иконки Twitter

class HomePage extends React.Component {
  render() {
    return (
      <div>
        <Header userName={this.props.userName} />
        <Content children={this.props.invitation} />
        <Footer showSocial />
      </div>
    )
  }
}
    

Тест-близнец - Правильный тест


describe('HomePage', () => {
  beforeEach(() => { /* <HomePage userName="vasya" invitation="hello"/> */ });
  it('renders social icons', () => {
    let twitterIcon =
      this.container.querySelector('a[href^="https://twitter.com"]');

    expect(twitterIcon).not.toBe(null);
  });
});
    

Тест-близнец - Опасность

Тест-близнец - А как надо?

Слишком
сложный
тест

Особенности тестирования фронтенда

Верстка и сеть

Верстка и сеть - Задача

ContactsViewModel

Верстка и сеть - Тест

Верстка и сеть - Правильный тест

Верстка и сеть - getter/setter

getter setter
React comp.state.myProp comp.setState({ myProp: 'new value' })
Angular $scope.myProp $scope.myProp = 'new value'
Knockout viewModel.myProp() viewModel.myProp('new value')
Backbone model.get('myProp') model.set('myProp', 'new value')

Верстка и сеть - паттерн Page Object

Описывает типовые действия с компонентом:
  1. Нахождение компонента
  2. Получение какого-то значения элемента (текст, атрибут)
  3. Произведение действия над компонентом:
    • Клики
    • Редактирование

Верстка и сеть - паттерн Page Object


test() {
    formPageObject.togglePartnersCheckbox();
    formPageObject.avitoBalloon().hide();
    formPageObject.toggleAvitoCheckbox();
    assert(formPageObject.avitoBalloon().visible === false);
}
    

Верстка и сеть - Советы

Асинхронный код

Асинхронный код - Код


class SearchForm extends React.Component {
  constructor(props) {
    this.handleChange =
      _.debounce(this.handleChange.bind(this), 200);
  }
  renderInput() {
    return <input ref="query" onChange={this.handleChange}/>;
  }
  handleChange() {
    this.setState({ state: 'loading' });
    fetchResults(this.refs.query.value).then(results => {
      this.setState({ state: 'loaded', results });
    });
  }
}
    

Асинхронный код - Код


class SearchForm extends React.Component {
  constructor(props) {
    this.handleChange =
      _.debounce(this.handleChange.bind(this), 200);
  }
  renderInput() {
    return <input ref="query" onChange={this.handleChange}/>;
  }
  handleChange() {
    this.setState({ state: 'loading' });
    fetchResults(this.refs.query.value).then(results => {
      this.setState({ state: 'loaded', results });
    });
  }
}
    

Асинхронный код - Код


class SearchForm extends React.Component {
  constructor(props) {
    this.handleChange =
      _.debounce(this.handleChange.bind(this), 200);
  }
  renderInput() {
    return <input ref="query" onChange={this.handleChange}/>;
  }
  handleChange() {
    this.setState({ state: 'loading' });
    fetchResults(this.refs.query.value).then(results => {
      this.setState({ state: 'loaded', results });
    });
  }
}
    

Асинхронный код - Код


class SearchForm extends React.Component {
  constructor(props) {
    this.handleChange =
      _.debounce(this.handleChange.bind(this), 200);
  }
  renderInput() {
    return <input ref="query" onChange={this.handleChange}/>;
  }
  handleChange() {
    this.setState({ state: 'loading' });
    fetchResults(this.refs.query.value).then(results => {
      this.setState({ state: 'loaded', results });
    });
  }
}
    

Асинхронный код - Тест

Асинхронный код - Правильный код


class SearchForm extends React.Component {
  constructor(props) {
    this.handleChange =
      _.debounce(this.handleChange.bind(this), 200);
  }
  renderInput() {
    return <input ref="query" onChange={this.handleChange}/>;
  }
  handleChange() {
    this.setState({ state: 'loading' });
    fetchResults(this.refs.query.value).then(results => {
      this.setState({ state: 'loaded', results });
    });
  }
}
    

Асинхронный код - Правильный код


class SearchForm extends React.Component {
  constructor(props) {
    this.handleChange =
      _.debounce(this.handleChange.bind(this), 200);
  }
  renderInput() {
    return <input ref="query" onChange={this.handleChange}/>;
  }
  handleChange() {
    this.setState({ state: 'loading' });
    fetchResults(this.refs.query.value).then(results => {
      this.setState({ state: 'loaded', results });
    });
  }
}
    

Асинхронный код - Правильный код


class SearchForm extends React.Component {
  constructor(props) {
    this.debouncedHandleChange =
      _.debounce(this.handleChange.bind(this), 200);
  }
  renderInput() {
    return <input ref="query" onChange={this.debouncedHandleChange}/>;
  }
  handleChange() {
    this.setState({ state: 'loading' });
    fetchResults(this.refs.query.value).then(results => {
      this.setState({ state: 'loaded', results });
    });
  }
}
    

Асинхронный код - Правильный тест

Асинхронный код - Советы

Слишком сложный тест - Опасность

Короче, Склифосовский

Оправдывает ли тест свое написание?

Критерии оценки

Вероятность ошибки

Поводы написать тест

Ощущение запутанности

Шаг 5. Понимание

Вывод 1. Модульное тестирование может спасти много времени

Вывод 2. Тесты не смогут отловить все баги

Нужно понять, что TDD всего лишь позволяет написать код, который делает то, что имел в виду разработчик.

Вывод 3. Во фронтенде тестировать нужно далеко не всё

Вывод 4. Фреймворк имеет значение

Спасибо за внимание!

Иван Стрелков, Avito

Презентация: istrel.github.io/testing-fronttalks/