Unit-тестирование
Не бывает совершенных программ, бывают недотестированные.
Пусть я получил вот такое задание:
Написать функцию, которая посчитает сумму всех целых чисел в заданном интервале [bottom, top]
И вот, что у меня получилось:
#include <iostream>
using namespace std;
int calculate(int bottom, int top) {
if (top > bottom) {
int sum = 0;
for (int i = bottom; i <= top; i++) {
sum += i;
}
return sum;
} else {
return 0;
}
}
int main() {
int a, b;
cin >> a >> b;
cout << calculate(a, b) << endl;
}
❗️ В этой функции есть баг! Вы уже его заметили? ❗️
Я перечитал код несколько раз и он кажется мне правильным, но я хочу убедиться, что все работает как надо.
Я могу проверить все возможные комбинации чисел вручную, но это займет много времени.
Лучше написать автоматические юнит тесты для функции calculate
, чтобы мы могли быстро и удобно проверять ее работу.
Юнит тесты
Давайте напишем код, который будет проверять другой код!
Для этого сделаем функцию test
, которая будет принимать всего 3 параметра:
- название теста
- полученный результат работы функции
- ожидаемый результат работы функции
Если тест находит ошибку, то хочется:
- увидеть, что на самом деле вернула функция;
- остановить выполнение программы, последней строкой вывода всегда был "упавший" тест.
void test(string testName, int actual, int expected) {
cout << "Test: " << testName << " ";
if (actual == expected) {
cout << "passed." << endl;
} else {
cout << "FAILED! Expected " << actual << " to be " << expected << endl;
exit(1);
}
}
Теперь напишем тестов!
Например, давайте проверим, что, вызвав функцию с аргументами 1 и 3 мы получим 6. Для этого прям первой строкой в main напишем:
test("1,3 = 6", calculate(1, 3), 6);
И после запуска программы консоль нам говорит Test: 1,3 = 6 passed.
Класс! Как же бесполезно получилось! Мы проверили очевидные вещи. Но не будем останавливаться. Давайте читать функцию и на каждое условие или конструкцию будем придумывать тесты. Для этого кода я вижу 3 интересных места:
int calculate(int bottom, int top) {
if (top > bottom) {
int sum = 0;
for (int i = bottom; i <= top; i++) {
sum += i;
}
return sum;
} else {
return 0;
}
}
Как теперь будем проверять:
- Для
top > bottom
- Надо проверить, когда
top > bottom
- Когда
top < bottom
- Раз мы сравниваем числа, то надо проверить и равенство
bottom == top
- Надо проверить, когда
- Для
i <= top
- Надо проверить, что мы считаем до верхней границы включительно.
- Для
else
блока- Проверим, что когда
top < bottom
, мы получаем 0
- Проверим, что когда
Давайте напишем тестов:
// 1.1 и 2.1 - одно и то же
test("1,3 = 6", calculate(1, 3), 6);
// 1.2 и 3.1 - одно и то же
test("3,1 = 0", calculate(3, 1), 0);
// 1.3
test("3,3 = 3", calculate(3, 3), 3);
А теперь запускаем:
➜ g++ test.cpp -o binary && ./binary
Test: 1,3 = 6 passed.
Test: 3,1 = 0 passed.
Test: 3,3 = 3 FAILED! Expected 0 to be 3
Ура! Ура, потому что проблема найдена мной, а не преподавателем!
А проблема в том, что наш if не проверяет равенство top
и bottom
.
Если они равны, то мы перейдем в else и получим 0.
Проблема решена: нужно заменить это на top >= bottom
!
После изменения кода, он работает, как надо. Но теперь, мне не нравится слово else. Я хочу отрефакторить код. И теперь я могу спокойно это сделать, ведь каждый раз после запуска программы, у нас будут запускаться тесты. Тесты гарантируют, что логика программы работает правильно!
Пользуясь правилом инверсии, у нас получится следующее:
#include <iostream>
using namespace std;
int calculate(int bottom, int top) {
if (top < bottom) {
return 0;
}
int sum = 0;
for (int i = bottom; i <= top; i++) {
sum += i;
}
return sum;
}
void test(string testName, int actual, int expected) {
cout << "Test: " << testName << " ";
if (actual == expected) {
cout << "passed." << endl;
} else {
cout << "FAILED! Expected " << actual << " to be " << expected << endl;
exit(1);
}
}
int main() {
test("1,3 = 6", calculate(1, 3), 6);
test("3,1 = 0", calculate(3, 1), 0);
test("3,3 = 3", calculate(3, 3), 3);
int a, b;
cin >> a >> b;
cout << calculate(a, b) << endl;
}
И все наши тесты проходят успешно:
➜ g++ test.cpp -o binary && ./binary
Test: 1,3 = 6 passed.
Test: 3,1 = 0 passed.
Test: 3,3 = 3 passed.
Теперь мы уверены, что никакие изменения кода не нарушат его логику работы, так как каждый раз будут запускаться тесты, проверяющие, что все работает правильно. Для проверки всей логики нам всего-то нужно запустить тесты. Они сделают за нас всю грязную работу.
Тестируем реальные задачи
Создать калькулятор, использующий побитовые операции.
В следующем коде есть пара проблем. Но, для того, чтобы показать влияние юнит тестов на код, чинить их сразу мы не будем. Давайте сразу выделим в коде, какие части программы нам важно протестировать.
#include <iomanip>
#include <iostream>
using namespace std;
int readNumber(int min, int max) { /* Тут все ясно */ }
int add(int a, int b) {
int res;
int carry = 0;
res = a ^ b;
carry = (a & b) << 1;
while (carry) {
int tmp = res;
res = res ^ carry;
carry = (tmp & carry) << 1;
}
return res;
}
int negate(int x) { return add(~x, 1); }
void subtract(int a, int b) {
a = readNumber(-100, 100);
b = readNumber(-100, 100);
cout << add(a, negate(b));
}
void multiply(int a, int b) {
cout << "a = ";
a = readNumber(-100, 100);
cout << "b = ";
b = readNumber(-100, 100);
a *= b;
cout << a;
}
void divide(int a, int b) {
cout << "a = ";
a = readNumber(-100, 100);
cout << "b = ";
b = readNumber(-100, 100);
a /= b;
cout << (int)a;
}
int run(int operation, unsigned char a, unsigned char b) {
enum chooseAnOperation { Addition, Subtraction, Division, Multiplication };
cout << "Choose an operation" << endl
<< "0-Addition" << endl
<< "1-Subtraction" << endl
<< "2-Division" << endl
<< "3-Multiplication" << endl;
operation = readNumber(0, 3);
chooseAnOperation chooseAnOperation[operation];
if (operation == Addition) {
a = readNumber(-100, 100);
b = readNumber(-100, 100);
cout << add(a, b);
} else if (operation == Subtraction) {
subtract(a, b);
} else if (operation == Division) {
divide(a, b);
} else if (operation == Multiplication) {
multiply(a, b);
}
}
int main() {
unsigned char a;
unsigned char b;
int operation;
run(operation, a, b);
return 0;
}
Всеми остальными частями задачи можно пренебречь, потому что они не производят критических вычислений.
Добавляем в нашу программу функцию test
и наметим тесты:
add
- Положительные числа, например 1 и 3
- Четные числа, например, 4 и 10 (мало ли)
- Что-нибудь с нолем, например 3 и 0
- Одно положительное и одно отрицательное, например 3 и -1
- Отрицательные числа, например -5 и -7
subtract
- Положительные числа, например 1 и 3
- Четные числа, например, 4 и 10 (мало ли)
- Что-нибудь с нолем, например 3 и 0
- Одно положительное и одно отрицательное, например 3 и -1
- Отрицательные числа, например -5 и -7
multiply
- Внутри у нас просто
*
, никаких пользовательских решений, поэтому любого одного теста будет достаточно
- Внутри у нас просто
divide
- Точно то же, что и multiply, просто деление
\
- Точно то же, что и multiply, просто деление
Теперь напишем тесты для add
:
test("add: 1, 3", add(1, 3), 4);
test("add: 4, 10", add(4, 10), 14);
test("add: 3, 0", add(3, 0), 3);
test("add: 3, -1", add(3, -1), 2);
test("add: -5, -7", add(-5, -7), -12);
Теперь для subtract
. И сразу стоп! subtract
- это void
фукнция!
Она ничего не возвращает. А нам нужно, чтобы результат actual
был числом.
Нужно поменять сигнатуру функции, чтобы теперь она возвращала значение.
Давайте изменим функцию так:
// Было
void subtract(int a, int b) {
a = readNumber(-100, 100);
b = readNumber(-100, 100);
cout << add(a, negate(b));
}
// Теперь функция просто принимает на вход 2 числа и оперирует ими.
// Заодно и кода меньше стало!
int subtract(int a, int b) { return add(a, negate(b)); }
// Пишем теперь тесты для нее:
test("subtract: 1, 3", subtract(1, 3), -2);
test("subtract: 4, 10", subtract(4, 10), -6);
test("subtract: 3, 0", subtract(3, 0), 3);
test("subtract: 3, -1", subtract(3, -1), 4);
test("subtract: -5, -7", subtract(-5, -7), 2);
Следующий! multiply
- та же проблема. Переписываем и пишем тесты
// Было
void multiply(int a, int b) {
cout << "a = ";
a = readNumber(-100, 100);
cout << "b = ";
b = readNumber(-100, 100);
a *= b;
cout << a;
}
// Стало
int multiply(int a, int b) { return a * b; }
// Тест
test("multiply: 5, 10", multiply(5, 10), 50);
Ну и divide
, уже по старой схеме:
// Было
void divide(int a, int b) {
cout << "a = ";
a = readNumber(-100, 100);
cout << "b = ";
b = readNumber(-100, 100);
a /= b;
cout << (int)a;
}
// Стало
int divide(int a, int b) { return a / b; }
// Тесты. Давайте в названии тестов укажем
// ЧТО проверяет тест, а не КАК он это делает
test("divide no round", divide(10, 2), 5);
test("divide with round", divide(10, 3), 3);
В консоли все тесты проходят, но вот программа теперь не работает, потому что мы сломали run
:
Test: add: 1, 3 passed.
Test: add: 4, 10 passed.
Test: add: 3, 0 passed.
Test: add: 3, -1 passed.
Test: add: -5, -7 passed.
Test: subtract: 1, 3 passed.
Test: subtract: 4, 10 passed.
Test: subtract: 3, 0 passed.
Test: subtract: 3, -1 passed.
Test: subtract: -5, -7 passed.
Test: multiply passed.
Test: divide no round passed.
Test: divide with round passed.
Choose an operation
0-Addition
1-Subtraction
2-Division
3-Multiplication
1
Чиним! Зная, что все функции калькулятора принимают числа, вынесем чтение чисел в функции run
для всех утилит, и внесем еще пару косметических исправлений:
#include <iomanip>
#include <iostream>
using namespace std;
int readNumber(int min, int max) { /* Тут все ясно */ }
int add(int a, int b) {
int res;
int carry = 0;
res = a ^ b;
carry = (a & b) << 1;
while (carry) {
int tmp = res;
res = res ^ carry;
carry = (tmp & carry) << 1;
}
return res;
}
int negate(int x) { return add(~x, 1); }
int subtract(int a, int b) { return add(a, negate(b)); }
int multiply(int a, int b) { return a * b; }
int divide(int a, int b) { return a / b; }
int run() {
enum Operation { Addition, Subtraction, Division, Multiplication };
cout << "Choose an operation" << endl
<< "0-Addition" << endl
<< "1-Subtraction" << endl
<< "2-Division" << endl
<< "3-Multiplication" << endl;
Operation operation = Operation(readNumber(0, 3));
// Создадим числа сразу, везде понадобятся.
int a = readNumber(-100, 100);
int b = readNumber(-100, 100);
cout << "a, b = " << a << ", " << b << endl;
if (operation == Addition) {
return add(a, b);
} else if (operation == Subtraction) {
return subtract(a, b);
} else if (operation == Division) {
return divide(a, b);
} else if (operation == Multiplication) {
return multiply(a, b);
} else {
// Мало ли...
return 0;
}
}
void test(string testName, int actual, int expected) {
cout << "Test: " << testName << " ";
if (actual == expected) {
cout << "passed." << endl;
} else {
cout << "FAILED! Expected " << actual << " to be " << expected << endl;
exit(1);
}
}
int main() {
// add
test("add: 1, 3", add(1, 3), 4);
test("add: 4, 10", add(4, 10), 14);
test("add: 3, 0", add(3, 0), 3);
test("add: 3, -1", add(3, -1), 2);
test("add: -5, -7", add(-5, -7), -12);
// subtract
test("subtract: 1, 3", subtract(1, 3), -2);
test("subtract: 4, 10", subtract(4, 10), -6);
test("subtract: 3, 0", subtract(3, 0), 3);
test("subtract: 3, -1", subtract(3, -1), 4);
test("subtract: -5, -7", subtract(-5, -7), 2);
// multiply
test("multiply", multiply(5, 10), 50);
// divide
test("divide no round", divide(10, 2), 5);
test("divide with round", divide(10, 3), 3);
int result = run();
cout << result << endl;
return 0;
}
И после запуска все хорошо:
Test: add: 1, 3 passed.
Test: add: 4, 10 passed.
Test: add: 3, 0 passed.
Test: add: 3, -1 passed.
Test: add: -5, -7 passed.
Test: subtract: 1, 3 passed.
Test: subtract: 4, 10 passed.
Test: subtract: 3, 0 passed.
Test: subtract: 3, -1 passed.
Test: subtract: -5, -7 passed.
Test: multiply passed.
Test: divide no round passed.
Test: divide with round passed.
Choose an operation
0-Addition
1-Subtraction
2-Division
3-Multiplication
1
10
5
a, b = 10, 5
5
Что дали нам эти тесты, если мы все равно не нашли ошибок?!
- Код программы стал лучше!
- Утилитарные функции стали чистыми. Они не зависят от пользовательского ввода и мы сможем их переиспользовать, если понадобится.
- Убрали дублирование
readNumber
, теперь мы вызываем его лишь по разу дляoperation
,a
иb
- Мы полностью уверены в том, что все наши функции работают правильно.
Если мы захотим изменить, скажем,
add
, то нам не придется руками проверять все заново. За нас это сделают. У нас есть целых13
тестов, которые проверят все возможные исходы пользовательского ввода.
Как тестировать разные типы данных
Используя перегрузку функций, можно легко и просто подстроиться под любые типы данных:
#include <iostream>
using namespace std;
int addOne(int a) { return a + 1; }
int* toArray(int a) {
int* arr = new int[2];
*arr = a;
*(arr + 1) = a + 1;
return arr;
}
// Вспомогательная функция, можно и без нее
bool areArraysEqual(int* a, int* b, int size) {
bool isEqual = true;
for (int i = 0; i < size; i++) {
if (a[i] != b[i]) {
isEqual = false;
break;
}
}
return isEqual;
}
void test(char name[], int num1, int num2) {
// Сравниваем 2 числа
if (num1 == num2) {
cout << "Good" << endl;
} else {
cout << "Test: " << name << " fails!" << endl;
exit(1);
}
}
// Перегружаем функцию. Тут работаем с массивами
void test(char name[], int* array1, int* array2, int size) {
// Тут надо сравнить 2 массива длины size
if (areArraysEqual(array1, array2, size)) {
cout << "Good" << endl;
} else {
cout << "Test: " << name << " fails!" << endl;
exit(1);
}
}
int main() {
test("int", addOne(1), 2); // int и int
// Компилятор gcc v12.2 позволяет сделать массив
// и инициализировать его на месте: new int[2]{1, 2}
test("array", toArray(1), new int[2]{1, 2}, 2); // int* и int* длины 2
// Если ваш компилятор ругается new int[2]{1, 2}, то либо
// 1. Обновить / установить более новый компилятор
// 2. Создать массив где-нибудь и передать его:
// int expected1[] = {1, 2}
// test("array", toArray(1), expected1, 2);
return 0;
}
Заключение
Тестирование приложений - неотъемлемая часть хорошего программного обеспечения. Помимо уверенности в корректной работе программы, тесты позволяют нам спокойно рефакторить код. Ведь теперь мы не боимся что-либо сломать. К тому же, тестирование влияет на структуру и код программы, заставляя нас писать проще и чище.