Тесты
Эпизод 1 — Скрытая угроза

Кувалдин Артем, Жигалов Сергей

Зачем нужны тесты?

Проработать исключительные сценарии

Сервис «Заметки»

Храним заметки пользователя в виде файлов

     GET /                   главная
        
     GET /notes              список заметок
        
    POST /notes              добавление заметки
        
     GET /notes/todo-list    просмотр заметки
        
    POST /notes          добавление заметки
        

{
    createdAt: 1456983804639,
    content: 'TODO list:
        1. Проверить домашние задания
        2. Рассказать лекцию про тесты
        3. Не забыть выложить слайды'
}
        

Сервис «Заметки»

Храним заметки пользователя в виде файлов

     GET /                   главная
        
     GET /notes              список заметок
        
    POST /notes              добавление заметки
        
     GET /notes/todo-list    просмотр заметки
        

Задача

«На основании первой строки заметки сгенерировать человеко-понятный урл»

'TODO list:\n1. Проверить...' // 'todo-list'
        

function generateNoteId(str) {
    return str
        .split('\n').shift()
        .toLowerCase()
        .replace(/\s/g, '-');
}

module.exports = generateNoteId;
        
    app/
    └── src
        └── generateNoteId.js
        └── ...
    └── test
        └── generateNoteId-test.js
        └── ...


// generateNoteId-test.js

var generateNoteId = require('../src/generateNoteId');
var assert = require('assert');
        
describe('Note id generator', function () {
    it('should cut first line', function () {
        var actual = generateNoteId('first\nsecond');
        assert.equal(actual, 'first');
    });
    it('should cast to lower case', function () {
        var actual = generateNoteId('ToDo');
        assert.equal(actual, 'todo');
    });
    it('should replace spaces to `-`', function () {
        var actual = generateNoteId('todo list');
        assert.equal(actual, 'todo-list');
    });
});

А что если

... передать не строку?


function generateNoteId(str) {
    if (typeof str !== 'string') {
        return;
    }

    return str
        .split('\n').shift()
        .toLowerCase()
        .replace(/\s/g, '-');
}
        

А что если

... передать не строку?

... служебные символы?

... лишние пробелы?

...


function generateNoteId(str) {
    if (typeof str !== 'string') {
        return;
    }

    return str
        .split('\n').shift()
        .replace(/[^a-z0-9\s]/g, '')
        .toLowerCase()
        .trim()
        .replace(/\s+/g, '-');
}

Правильно организовать код

Код пишем так, чтобы

  • ... удобно передавать параметры
  • ... не было дублирования

⬇
Модули могут решать несколько задач

⬇
Сложнее в поддержке

Тестируемый код

  • Изолированные модули
  • Атомарные функции

⬇
Легче менять реализацию

⬇
Легче в поддержке

Тестируемый код

≠

Код для тестов


function generateNoteId(str) {
    if (typeof str === 'string') {
        str = str.split('\n').shift().toLowerCase();
        return replaceSpaces(str);
    }
}

function replaceSpaces(str)
    return str
        .replace(/[^a-z0-9\s]/g, '')
        .trim()
        .replace(/\s+/g, '-');
}

    module.exports = generateNoteId;

Изменение кода

Рефакторинг

«это процесс улучшения написанного ранее кода путем такого изменения его внутренней структуры, которое не влияет на внешнее поведение»

При рефакторинге сохраняется функциональность

Тесты проверяют функциональность

⬇

Тесты помогают рефакторить

  • Версии не всегда обновляются правильно
  • Обновление мажорных версий
⬇

Тесты помогают обновлять зависимости

Тестовый отчет

  Note id generator
    ✓ should cut first line
    ✓ should cast to lower case
    ✓ should replace spaces to `-`
    ✓ should return `undefined` when input data ...
    ✓ should exclude unknown symbols
    ✓ should exclude extra spaces
    ✓ should trim string
    - should translit

  7 passing (17ms)
  1 pending
  • «живая» документация
  • пример запуска кода
  • todo-list

TDD

«разработка через тестирование»

Цикл разработки

  1. Тест на желаемое поведение
  2. Новые тесты не проходят
  3. Код который чинит тест
  4. Все тесты проходят
  5. Рефакторинг
  6. GOTO 1

Кент Бек

«разработка через тестирование поощряет простой дизайн и внушает уверенность»

Тестовые кейсы

  • +1 тест на каждое действие
  • +1 тест на исключение
  • +1 тест на ветвление

Тест должен содержать одну логическую проверку которая не повторяется в других тестах*

[*] в идеале ;)

function generateNoteId(str) {
    if (typeof str !== 'string') {
        return;
    }
        

    return str
        .split('\n').shift()
        .toLowerCase()
        .replace(/[^a-z0-9\s]/g, '')
        .trim()
        .replace(/\s+/g, '-');
}

function generateNoteId(str) {
    if (typeof str !== 'string') {
        return;
    }
        

    return str
        .split('\n').shift()
        .toLowerCase()
        .replace(/[^a-z0-9\s]/g, '')
        .trim()
        .replace(/\s+/g, '-');
}

function generateNoteId(str) {
    if (typeof str !== 'string') {
        return;
    }
        

    return str
        .split('\n').shift()
        .toLowerCase()
        .replace(/[^a-z0-9\s]/g, '')
        .trim()
        .replace(/\s+/g, '-');
}

function generateNoteId(str) {
    if (typeof str !== 'string') {
        return;
    }
        

    return str
        .split('\n').shift()
        .toLowerCase()
        .replace(/[^a-z0-9\s]/g, '')
        .trim()
        .replace(/\s+/g, '-');
}

function generateNoteId(str) {
    if (typeof str !== 'string') {
        return;
    }
        

    return str
        .split('\n').shift()
        .toLowerCase()
        .replace(/[^a-z0-9\s]/g, '')
        .trim()
        .replace(/\s+/g, '-');
}

function generateNoteId(str) {
    if (typeof str !== 'string') {
        return;
    }
        

    return str
        .split('\n').shift()
        .toLowerCase()
        .replace(/[^a-z0-9\s]/g, '')
        .trim()
        .replace(/\s+/g, '-');
}

function generateNoteId(str) {
    if (typeof str !== 'string') {
        return;
    }
        

    return str
        .split('\n').shift()
        .toLowerCase()
        .replace(/[^a-z0-9\s]/g, '')
        .trim()
        .replace(/\s+/g, '-');
}
 Note id generator
   ✓ should cut first line
   ✓ should cast to lower case
   ✓ should replace spaces to `-`
   ✓ should return `undefined` when input data ...
   ✓ should exclude unknown symbols
   ✓ should exclude extra spaces
   ✓ should trim string

   7 passing (17ms)

Организация тестов

три части

  1. [Подготовка]
  2. Действие
  3. Проверка

it('should cast to lower case', function () {
    // действие
    var actual = generateNoteId('HellO');
        
    // проверка
    actual.should.be.equal('hello');
});

Инструменты

Mocha

«это фреймворк для тестирования JavaScript приложений»

mocha

$ npm install mocha --save-dev
$ mocha generateNoteId-test.js

Note id generator
  ✓ should cast to lower case
  ✓ should replace spaces to `-`

2 passing (8ms)

package.json


{
    "scripts": {
        "test": "mocha test"
    }
}
        

$ npm test

Cпецификация

describe('Note id generator', function() {
    it('should cast to lower case', function () {
        var actual = generateNoteId('HellO');
        assert.equal(actual, 'hello');
    });
 
    it('should replace spaces to `-`', function () {
        var actual = generateNoteId('mu ha ha');
        assert.equal(actual, 'mu-ha-ha');
    });
});

describe > describe > ...


describe('Note id generator', function () {
    describe('spaces', function () {
        it('should replace to `-`', function () {
            var actual = generateNoteId('mu ha ha');
            assert.equal(actual, 'mu-ha-ha');
        });

        it('should exclude extra spaces', function () {
            var actual = generateNoteId('  hel  lo ');
            actual.should.be.equal('hel-lo');
        });
    });
});
Note id generator
  spaces
    ✓ should replace to `-`
    ✓ should exclude extra spaces

  2 passing (8ms)

Что тестируем?


describe('Note id generator', function () {
    it('should cast to lower case', function () {
        var actual = generateNoteId('HellO');
        assert.equal(actual, 'hello');
    });

    it('should replace spaces to `-`', function () {
        var actual = generateNoteId('mu ha ha');
        assert.equal(actual, 'mu-ha-ha');
    });
});

Что должно произойти?


describe('Note id generator', function () {
    it('should cast to lower case', function () {
        var actual = generateNoteId('HellO');
        assert.equal(actual, 'hello');
    });

    it('should replace spaces to `-`', function () {
        var actual = generateNoteId('mu ha ha');
        assert.equal(actual, 'mu-ha-ha');
    });
});

Hooks

describe('My favorite module', function() {
    before(function() {
        // runs before all tests in this block
    });
    after(function() {
        // runs after all tests in this block
    });
    beforeEach(function() {
        // runs before each test in this block
    });
    afterEach(function() {
        // runs after each test in this block
    });
    // ...
});
function createNote(createdAt, content){
   return MongoClient
      .connect(url)
      .then(function (db) {
         return db.collection('notes')
           .insert({
               createdAt,
               content
           });
      });
};
before(function (done) {
    MongoClient.connect(url)
        .then(function (db) {
            connect = db;
        })
        .then(done, done);
});
beforeEach(function (done) {
    connect
        .collection('notes')
        .remove({}, () => done);
});
after(function (done) {
    return connect.close();
});
it('should create note', function (done) {
   createNote(123, 'myNote')
      .then(function () {
         return connect.collection('notes')
            .find({}).toArray();
      })
      .then(function (actual) {
         assert.equal(actual.length, 1);
         assert.equal(actual[0].createdAt, 123);
         assert.equal(actual[0].content, 'myNote');
      })
      .then(done, done);
});

assert

assert.ok

? == true

// Success
assert(true);
assert.ok(true);
assert.ok('mu-ha-ha');
assert.ok({});

assert.ok

? == true

// Throw error
assert(false);
assert.ok(false);
assert.ok('');
assert.ok(0);
assert.ok(null);

assert.equal

? == ?

// Success
assert.equal(true, true);
assert.equal('xo', 'x' + 'o');
assert.equal('1', 1);

// Throw error
assert.equal({a: 1}, {a: 1});

assert.strictEqual

? === ?

// Success
assert.strictEqual(true, true);
assert.strictEqual('xo', 'x' + 'o');

// Throw error
assert.strictEqual('1', 1);
assert.strictEqual({a: 1}, {a: 1});

assert.deepEqual


// Success
assert.deepEqual(true, true);
assert.deepEqual('xo', 'x' + 'o');
assert.deepEqual('1', 1);
assert.deepEqual({a: 1}, {a: 1});
«Проверить что переменная notes является массивом»

notes instanceof Array // true
           

var notes = '';
assert.ok(notes instanceof Array);
           

1) Assert is array:

 AssertionError: false == true
 + expected - actual

 -false
 +true

var notes = '';
assert.ok(notes instanceof Array,
        'notes is not array');
           

1) Assert is array:

 AssertionError: notes is not array
 + expected - actual

 -false
 +true

chai

$ npm install chai --save-dev

require('chai').should();
           

var notes = '';
notes.should.be.an.instanceof(Array);

1) Assert is array:

 AssertionError: expected '' to be an
                 instance of Array

Отладка тестов

Только один тест

describe('Note id generator', function () {
    it.only('should cast to lower case', function () {
        var actual = generateNoteId('HellO');
        assert.equal(actual, 'hello');
    });

    it('should replace spaces to `-`', function () {
        var actual = generateNoteId('mu ha ha');
        assert.equal(actual, 'mu-ha-ha');
    });
});

Только группу тестов

describe.only('Note id generator', function () {
    it('should cast to lower case', function () {
        var actual = generateNoteId('HellO');
        assert.equal(actual, 'hello');
    });

    it('should replace spaces to `-`', function () {
        var actual = generateNoteId('mu ha ha');
        assert.equal(actual, 'mu-ha-ha');
    });
});

Пропустить тест

describe('Note id generator', function () {
    it.skip('should cast to lower case', function () {
        var actual = generateNoteId('HellO');
        assert.equal(actual, 'hello');
    });

    it('should replace spaces to `-`', function () {
        var actual = generateNoteId('mu ha ha');
        assert.equal(actual, 'mu-ha-ha');
    });
});

Пропустить группу тестов

describe.skip('Note id generator', function () {
    it('should cast to lower case', function () {
        var actual = generateNoteId('HellO');
        assert.equal(actual, 'hello');
    });

    it('should replace spaces to `-`', function () {
        var actual = generateNoteId('mu ha ha');
        assert.equal(actual, 'mu-ha-ha');
    });
});

Запуск тестов на WebStorm

Отчеты

$ mocha test -r spec
$ mocha test -r dot
$ mocha test -r min
$ mocha test -r nyan

Подмена

Задача

«На основании первой строки заметки сгенерировать человеко-понятный урл латинскими символами»

var translit = require('translit');

function generateNoteId(str) {
    if (typeof str !== 'string') {
        return;
    }
        
    return translit(str)
        .split('\n').shift()
        .toLowerCase()
        .replace(/[^a-z0-9\s]/g, '')
        .trim()
        .replace(/\s+/g, '-');
}

it('should translit russian characters', function () {
    var actual = generateNoteId('привет');

    actual.should.be.equal('privet');
});
        

mockery


$ npm install mockery --save-dev
        

mockery


var mockery = require('mockery');
        

it('should translit russian characters', function (){
    function transliteMock (input) {
        input.should.be.equal('Привет, мир!');
        return 'Privet, mir!';
    }
    mockery.registerMock('translit', transliteMock);
    mockery.enable();
    generateNoteId = require('../src/generateNoteId');
        
    var actual = generateNoteId('Привет, мир!');

    actual.should.be.equal('privet-mir');
});

sinon

$ npm install sinon --save-dev

Stub

it('should translit russian characters', function () {
    var stub = sinon.stub();
    stub.withArgs('Привет, мир!')
        .onFirstCall().returns('Privet, mir!');
    stub.throws(Error('wrong translit argument'));
    mockery.registerMock('translit', stub);
    mockery.enable();
    translit = require('../src/generateNoteId').translit;

    var actual = translit('привет');
    actual.should.be.equal('privet-mir');
});

Spy

javascript-tasks-5

«Студента можно подписать на событие, производимое преподавателем»
var getEmitter = require('./emitter');
var lecturer = getEmitter();

// subscribe daria to event `slide`
lecturer.on('slide', daria, function (){
    console.log('Oho!');
});

// emit event `slide`
lecturer.emit('slide'); // Oho!
var sinon = require('sinon');
var emitter = require('../src/emitter')();

it('should call handler when', function () {
    var spy = sinon.spy();

    emitter.on('slide', daria, spy);
    emitter.emit('slide');

    spy.calledOnce.should.be.true;
});
var sinon = require('sinon');
var emitter = require('../src/emitter')();

it('should call handler when', function () {
    var spy = sinon.spy(function () {
        console.log('Mu-ha-ha!');
    });

    emitter.on('slide', daria, spy);
    emitter.emit('slide'); // 'Mu-ha-ha!'

    spy.calledOnce.should.be.true;
});
it('should not call handler', function () {
    var spy = sinon.spy();

    emitter.on('slide', daria, spy);
    emitter.emit('funny');

    spy.called.should.be.false;
});
it('should call handler twice', function () {
    var spy = sinon.spy();

    emitter.on('slide', daria, spy);
    emitter.emit('slide');
    emitter.emit('slide');

    spy.callCount.should.equal(2);
});
it('should call handler with args', function () {
    var spy = sinon.spy();

    emitter.on('slide', daria, spy);
    emitter.emit('slide', 'send data');

    var firstCall = spy.getCall(0);
    firstCall.args.length.should.equal(1);
    firstCall.calledWith('send data').should.be.true;
});

Подмена сетевых запросов


it('should translate', function () {
   return generateNoteId('привет')
       .then(function (actual) {
           actual.should.be.equal('hi');
       });
});
       

nock


it('should translate', function () {
   nock('https://translate.yandex.net')
       .get('/api/v1.5/tr.json/translate')
       .query(true)
       .reply(200, {text: ['Hello, world!']});
           
    return translate('привет')
       .then(function (actual) {
           actual.should.be.equal('hello-world');
       });
});

Supertest

Задача

Получить данные о заметке, прейдя по урлу '/notes/:name'.
//routes.js
const pages = require('./controllers/pages');
const notes = require('./controllers/notes');

module.exports = function(app) {
    app.post('/notes', notes.create);
    app.get('/notes/:name', notes.item);
    app.all('*', pages.error404)
};
const request = require('supertest');
const app = require('../app');

describe('Note page', function () {
    it('should return note', function (done){
        request(app)
            .get('/notes/films')
            .set('Cookie', 'top secret cookie')
            .expect('Content-Type', 'text/html')
            .expect(200, done)
    }); 
    it('should respond 404', function (done){
        request(app)
            .get('/notes/wrongNote')
            .expect(404, done)
    }); 
    it('should create note', function (done){
        request(app)
            .post('/notes')
            .send({...})
            .expect(200, done)
            .end(function (err, res) {
                var body = res.body;
                var expectedBody = {...};
                body.should.deep.equal(expectedBody)
                done(err);
            });
    });
}); 

Code coverage

«мера, которая показывает, на сколько исходный код в проекте был протестирован»

istanbul

$ npm install -g istanbul
$ istanbul cover _mocha
$ open coverage/lcov-report/index.html

Домашнее задание

github.com/urfu-2015/webdev-tasks-3