Тесты
Эпизод 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