Автотесты

Стратегии тестирования. TDD. Mock. Nock. Sinon

Жигалов Сергей

1. Сколько тестов писать?

  • Чтобы покрыть все сценарии
  • Не должно быть дублирующих тестов

1. Сколько тестов писать? Пример


it(`should throw error when dice is string`, () => {
  const cb = () => getPokerHand('hello');

  assert.throws(cb, /Dice is not an array/);
});

it(`should throw error when dice is empty`, () => {
  const cb = () => getPokerHand();

  assert.throws(cb, /Dice is not an array/);
});
        

Стратегии выбора тест-кейсов

Критерии полноты тестирования

Белый ящик

White box

Белый ящик

При выборе тест-кейсов смотрим на исходный код: каждый оператор в программе должен быть выполнен хотябы один раз.

Белый ящик Пример


function getPokerHand(dice) {
    if (dice.length != 5)
        throw new Error('Wrong amount of dices');

    var combo = dice.reduce(function(acc, val) {
        if(val < 1 || val > 6 || !Number.isInteger(val))
            throw new Error('Dice should be int from 1 to 6');

        /* ... */
    });
}
        

Черный ящик

Black box

Черный ящик

Тест-кейсы выбираем исходя из того, как должна работать программа

Черный ящик Пример

  • на вход подается не массив
  • на вход ничего не подаётся
  • размер массива больше 5
  • размер массива меньше 5
  • элемент массива не число
  • элемент массива меньше 1
  • элемент массива больше 6
  • элемент массива дробное число
  • ...

Черный ящик Пример

  • возвращает `Каре` для [1, 1, 1, 1, 2]
  • возвращает `Фулл хаус` для [1, 1, 1, 2, 2]
  • возвращает `Тройка` для [1, 1, 1, 2, 3]
  • возвращает `Две пары` для [1, 1, 2, 2, 3]
  • возвращает `Пара` для [1, 1, 2, 3, 4]
  • возвращает `Наивысшее очко` для [1, 2, 3, 4, 5]
  • ...

2. Тесты похожи


it('should return `Покер` for [6, 6, 6, 6, 6]', () => {
    const actual = getPokerHand([6, 6, 6, 6, 6]);

    assert.equal(actual, 'Покер');
});
it('should return `Каре` for [6, 6, 3, 6, 6]', () => {
    const actual = getPokerHand([6, 6, 3, 6, 6]);

    assert.equal(actual, 'Каре');
});
it('should return `Фулл хаус` for [6, 1, 1, 6, 6]', () => {
    const actual = getPokerHand([6, 1, 1, 6, 6]);

    assert.equal(actual, 'Фулл хаус');
});
        

function runSuccessTest(dice, expected) {
    const actual = getPokerHand(dice);

    assert.equal(actual, expected);
}
        

it('should return `Покер` for [6, 6, 6, 6, 6]', () => {
    runSuccessTest([6, 6, 6, 6, 6], 'Покер');
});

it('should return `Каре` for [6, 6, 3, 6, 6]', () => {
    runSuccessTest([6, 6, 3, 6, 6], 'Каре');
});

it('should return `Фулл хаус` for [6, 1, 1, 6, 6]', () => {
    runSuccessTest([6, 1, 1, 6, 6], 'Фулл хаус');
});
        

function runSuccessTest(dice, expected) {
    return () => {
        const actual = getPokerHand(dice);

        assert.equal(actual, expected);
    }
}
        

it('should return `Покер` for [6, 6, 6, 6, 6]',
    runSuccessTest([6, 6, 6, 6, 6], 'Покер'));

it('should return `Каре` for [6, 6, 3, 6, 6]',
    runSuccessTest([6, 6, 3, 6, 6], 'Каре'));

it('should return `Фулл хаус` for [6, 1, 1, 6, 6]',
    runSuccessTest([6, 1, 1, 6, 6], 'Фулл хаус'));
        

[
    { dice: [6, 6, 6, 6, 6], expected: 'Покер' },
    { dice: [6, 6, 3, 6, 6], expected: 'Каре' },
    { dice: [6, 1, 1, 6, 6], expected: 'Фулл хаус' },
].forEach(test =>
    it(`should return ${test.expected} for [${test.dice}]`,()=>{
        const actual = getPokerHand(test.dice);

        assert.equal(actual, test.expected);
    }
);
        

Подходы к разработке

Test Last Development

TLD
  1. Написать код
  2. Покрыть код тестами
  3. Проверить что тесты проходят

TLD Преимущества

  • Естественный процесс
  • Нет накладных расходов
  • Легко писать тесты

Test Driven Development

TDD
  1. Описываем поведение в тесте
  2. Проверяем что тест не проходит
  3. Реализуем поведение в коде
  4. Проверяем что тест проходит
  5. Рефакторинг

Test Driven Development

tdd

TDD Пример

  • на вход подается не массив
  • размер массива больше 5
  • размер массива меньше 5
  • ...

TDD 1.1 Тест


describe('getPokerHand', () => {
    it('should throw error when dice is not array', () => {
        const cb = () => getPokerHand('not array');

        assert.throws(cb, /Arguments is not array/);
    });
});
        

TDD 1.2 Тест не проходит


getPokerHand
  1) should throw error when dice is not array


0 passing (9ms)
1 failing

1) getPokerHand should throw error when dice is not array:
   TypeError: getPokerHand is not a function
        

TDD 1.3 Код


function getPokerHand(dice) {
    if(!Array.isArray(dice)) {
        throw new Error('Arguments is not array');
    }
}
        

TDD 1.4 Тест проходит


getPokerHand
  ✓ should throw error when dice is not array


1 passing (6ms)
        

TDD 1.5 Рефакторинг

Всё и так хорошо 👌

  • ✓ на вход подается не массив
  • размер массива больше 5
  • размер массива меньше 5
  • ...

TDD 2.1 Тест


        describe('getPokerHand', () => {
            it('should throw error when dice is not array', /*...*/);
            it('should throw error when dice length great 5', () => {
                const cb = () => getPokerHand([1, 2, 3, 4, 5, 6]);

                assert.throws(cb, /Arguments length not equal 5/);
            });
        });
        

TDD 2.2 Тест не проходит


getPokerHand
  ✓ should throw error when dice is not array
  1) should throw error when dice length great 5


1 passing (8ms)
1 failing

1) getPokerHand should throw error when dice length great 5:
   AssertionError: Missing expected exception..
        

TDD 2.3 Код


function getPokerHand(dice) {
    if(!Array.isArray(dice)) {
        throw new Error('Arguments is not array');
    }

    if (dice.length > 5) {
        throw new Error('Arguments length not equal 5');
    }
}
        

TDD 2.4 Тест проходит


getPokerHand
  ✓ should throw error when dice is not array
  ✓ should throw error when dice length great 5


2 passing (7ms)
        

TDD 2.5 Рефакторинг

По-прежнему всё хорошо 👌

  • ✓ на вход подается не массив
  • ✓ размер массива больше 5
  • размер массива меньше 5
  • ...

TDD 3.1 Тест


describe('getPokerHand', () => {
    it('should throw error when dice is not array', /*...*/);
    it('should throw error when dice length great 5', /*...*/);
    it('should throw error when dice length less 5', () => {
        const cb = () => getPokerHand([1, 2, 3, 4]);

        assert.throws(cb, /Arguments length not equal 5/);
    });
});
        

TDD 3.2 Тест не проходит


getPokerHand
  ✓ should throw error when dice is not array
  ✓ should throw error when dice length great 5
  1) should throw error when dice length less 5


2 passing (9ms)
1 failing

1) getPokerHand should throw error when dice length less 5:
   AssertionError: Missing expected exception..
        

TDD 3.3 Код


function getPokerHand(dice) {
    if(!Array.isArray(dice)) {
        throw new Error('Arguments is not array');
    }

    if (dice.length > 5) {
        throw new Error('Arguments length not equal 5');
    }

    if (dice.length < 5) {
        throw new Error('Arguments length not equal 5');
    }
}
        

TDD 3.4 Тест проходит


getPokerHand
  ✓ should throw error when dice is not array
  ✓ should throw error when dice length great 5
  ✓ should throw error when dice length less 5


3 passing (8ms)
        

TDD 3.5 Рефакторинг


function getPokerHand(dice) {
    if(!Array.isArray(dice)) {
        throw new Error('Arguments is not array');
    }

    if (dice.length !== 5) {
        throw new Error('Arguments length not equal 5');
    }
}
        

TDD Преимущества

  • 100% покрытие кода тестами
  • Продумать поведение до реализации
  • Меньше ложно-положительных тестов
  • Обнаружение 🐞 на ранней стадии

Тестирование нескольких модулей

Определить победителя в игре "покер на костях"

Решение


// playPoker.js

const getPokerHand = require('./getPokerHand');
const pokerHands = [
    'Наивысшее очко',
    'Пара',
    'Две пары',
    'Тройка',
    'Фулл хаус',
    'Каре',
    'Покер'
];

function playPoker(firstDice, secondDice) {/* ... */}
        

Решение


// playPoker.js

function playPoker(firstDice, secondDice) {
    const first = getPokerHand(firstDice);
    const second = getPokerHand(secondDice);

    const compareHands =
        pokerHands.indexOf(first) -
        pokerHands.indexOf(second);

    return compareHands === 0
        ? 'Ничья'
        : compareHands > 0 ? 'Первый' : 'Второй';
}
        

Как тестировать?

Mock-объект

(от англ. mock object, буквально: «объект-пародия», «объект-имитация», а также «подставка») — тип объектов, реализующих заданные аспекты моделируемого программного окружения.

proxyquire

⭐️ 1,588


npm install proxyquire --save-dev
        

const proxyquire =  require('proxyquire');

proxyquire({string} request, {Object} stubs);
        

// playPoker.js
const getPokerHand = require('./getPokerHand');
const pokerHands = [/* ... */];

function playPoker(firstDice, secondDice) {
    const first = getPokerHand(firstDice);
    const second = getPokerHand(secondDice);
    const compareHands =
        pokerHands.indexOf(first) -
        pokerHands.indexOf(second);

    return compareHands === 0
        ? 'Ничья'
        : compareHands > 0 ? 'Первый' : 'Второй';
}
        

proxyquire


// tests/playPoker-test.js

it('should return `Ничья` for equal poker hand', () => {
    const playPoker = proxyquire('../playPoker', {
        './getPokerHand': () => 'Пара'
    });
    const actual = playPoker([1, 1, 2, 3, 4], [1, 1, 2, 3, 4]);

    assert.equal(actual, 'Ничья');
});
        

proxyquire


// tests/playPoker-test.js

it('should return `Первый` when first hand great', () => {
    const answers = ['Каре', 'Тройка']
    const playPoker = proxyquire('../playPoker', {
        './getPokerHand': () => answers.shift()
    });
    const actual = playPoker([1, 1, 1, 1, 4], [1, 1, 1, 3, 4]);

    assert.equal(actual, 'Первый');
});
        

Sinon stub

Test stubs are functions with pre-programmed behavior.

Sinon stub Установка


npm install sinon --save-dev
        

const sinon = require('sinon');
        

Sinon stub Использование


const getPokerHand = sinon.stub();

getPokerHand.withArgs([1, 1, 2, 3, 4]).returns('Пара');
getPokerHand.withArgs([1, 1, 2, 3, 5]).returns('Пара');
        

Sinon stub Тест


// tests/playPoker-test.js

it('should return `Ничья` for equal poker hand', () => {
    const getPokerHand = sinon.stub();
    getPokerHand.withArgs([1, 1, 2, 3, 4]).returns('Пара');
    getPokerHand.withArgs([1, 1, 2, 3, 5]).returns('Пара');

    const playPoker = proxyquire('../playPoker', {
        './getPokerHand': getPokerHand
    });
    const actual = playPoker([1, 1, 2, 3, 4], [1, 1, 2, 3, 5]);

    assert.equal(actual, 'Ничья');
});
        

Sinon stub Тест


playPoker
  ✓ should return `Ничья` for equal poker hand


1 passing (13ms)
        

Sinon stub Тест


// tests/playPoker-test.js

it('should return `Ничья` for equal poker hand', () => {
    const getPokerHand = sinon.stub();
    getPokerHand.withArgs([1, 1, 2, 3, 4]).returns('Пара');
    getPokerHand.withArgs([1, 1, 2, 3, 5]).returns('Пара');
    getPokerHand.throws('Illegal arguments');

    const playPoker = proxyquire('../playPoker', {
        './getPokerHand': getPokerHand
    });
    const actual = playPoker([1, 1, 2, 3, 4], [1, 1, 2, 3, 6]);

    assert.equal(actual, 'Ничья');
});
        

Sinon stub Тест


playPoker
  1) should return `Ничья` for equal poker hand


0 passing (12ms)
1 failing

1) playPoker should return `Ничья` for equal poker hand:

Illegal arguments
    at Context.it (tests/playPoker-test.js:10:22)
        
Вывести на консоль результат игры.

Решение


// poker.js

const playPoker = require('./playPoker');

function poker(firstDice, secondDice) {
    try {
        const result = playPoker(firstDice, secondDice);

        console.log(result);
    } catch(error) {
        console.error(error.message);
    }
}
        

Sinon spy

A test spy is a function that records arguments, return value, the value of this and exception thrown for all its calls.

Sinon spy Тест


// tests/poker-test.js

it('should print success result', () => {
    const log = sinon.spy(console, 'log');
    const error = sinon.spy(console, 'error');

    /* ... */
});
        

Sinon spy Тест


it('should print success result', () => {
    /* ... */

    const playPoker = sinon.stub();
    playPoker
        .withArgs([1, 2, 3, 4, 5], [1, 2, 3, 4, 6])
        .returns('Ничья');
    const poker = proxyquire('../poker', {
        './playPoker': playPoker
    });

    poker([1, 2, 3, 4, 5], [1, 2, 3, 4, 6]);

    /* ... */
});
        

Sinon spy Тест


it('should print success result', () => {
    /* ... */

    assert(log.calledOnce);
    assert(log.calledWith('Ничья'));

    assert(!error.called);
});
        

Sinon spy Тест


afterEach(() => {
    console.log.restore();
    console.error.restore();
});
        

Sinon spy Запускаем


    Poker
  Ничья
      ✓ should print success result


    1 passing (13ms)
        

spy Vs stub


it('should print success result', () => {
    const log = sinon.stub(console, 'log');
    const error = sinon.stub(console, 'error');

    /* ... */
}
        

spy Vs stub


    Poker


2 passing (28ms)
        
Отрисовать прогноз погоды в консоли
curl http://wttr.in/ekaterinburg
weather

Решение


// weather.js

const request = require('request');
const url = 'https://api.weather.yandex.ru/v1/forecast';
        

https://api.weather.yandex.ru/v1/forecast

Решение


function weather(cb) {
    request(url, (requestError, res, body) => {
        if (requestError || res.statusCode !== 200) {
            return cb(requestError.message);
        }

        try {
            const data = JSON.parse(body);
            cb(null, data.fact.temp);
        } catch (parseError) {
            cb(parseError);
        }
    });
}
        

nock


npm install nock --save-dev
        

nock('https://api.weather.yandex.ru')
    .get('/v1/forecast')
    .reply(200, '{"fact":{"temp":25}}');
        

nock


// tests/weather-test.js

it('should print temperature', done => {
    nock('https://api.weather.yandex.ru')
        .get('/v1/forecast')
        .reply(200, '{"fact":{"temp":25}}');

    weather((error, actual) => {
        assert.equal(actual, 25);
        done(error);
    });
});
        

nock


// tests/weather-test.js

it('should return error when request failed', done => {
    nock('https://api.weather.yandex.ru')
        .get('/v1/forecast')
        .reply(500, 'Internal server error');

    weather((error, actual) => {
        assert.equal(error, 'Request failed');
        done();
    });
});
        

nock


// tests/weather-test.js

afterEach(nock.cleanAll);
        

Почитать