Unit-тестирование

2023-03-02
29 мин.

meme

Не бывает совершенных программ, бывают недотестированные.

Пусть я получил вот такое задание:

Написать функцию, которая посчитает сумму всех целых чисел в заданном интервале [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, чтобы мы могли быстро и удобно проверять ее работу.

Юнит тесты

Давайте напишем код, который будет проверять другой код!

meme2

Для этого сделаем функцию test, которая будет принимать всего 3 параметра:

Если тест находит ошибку, то хочется:

  1. увидеть, что на самом деле вернула функция;
  2. остановить выполнение программы, последней строкой вывода всегда был "упавший" тест.
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;
  }
}

Как теперь будем проверять:

  1. Для top > bottom
    1. Надо проверить, когда top > bottom
    2. Когда top < bottom
    3. Раз мы сравниваем числа, то надо проверить и равенство bottom == top
  2. Для i <= top
    1. Надо проверить, что мы считаем до верхней границы включительно.
  3. Для else блока
    1. Проверим, что когда 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 и наметим тесты:

  1. add
    1. Положительные числа, например 1 и 3
    2. Четные числа, например, 4 и 10 (мало ли)
    3. Что-нибудь с нолем, например 3 и 0
    4. Одно положительное и одно отрицательное, например 3 и -1
    5. Отрицательные числа, например -5 и -7
  2. subtract
    1. Положительные числа, например 1 и 3
    2. Четные числа, например, 4 и 10 (мало ли)
    3. Что-нибудь с нолем, например 3 и 0
    4. Одно положительное и одно отрицательное, например 3 и -1
    5. Отрицательные числа, например -5 и -7
  3. multiply
    1. Внутри у нас просто *, никаких пользовательских решений, поэтому любого одного теста будет достаточно
  4. divide
    1. Точно то же, что и 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

Что дали нам эти тесты, если мы все равно не нашли ошибок?!

  1. Код программы стал лучше!
    1. Утилитарные функции стали чистыми. Они не зависят от пользовательского ввода и мы сможем их переиспользовать, если понадобится.
    2. Убрали дублирование readNumber, теперь мы вызываем его лишь по разу для operation, a и b
  2. Мы полностью уверены в том, что все наши функции работают правильно. Если мы захотим изменить, скажем, 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;
}

Заключение

Тестирование приложений - неотъемлемая часть хорошего программного обеспечения. Помимо уверенности в корректной работе программы, тесты позволяют нам спокойно рефакторить код. Ведь теперь мы не боимся что-либо сломать. К тому же, тестирование влияет на структуру и код программы, заставляя нас писать проще и чище.

ММФ-ноутс, ммф нотс, ммф-ноутс, mmf, notes, mmf-notes