Тестирование кода в C++: подробное руководство
Что это такое и зачем? (аналогия из реальной жизни)
Представь, что ты написал программу — калькулятор, который должен складывать, вычитать, умножать и делить числа. Ты потратил несколько часов, написал код, запустил его... и он работает! Но как убедиться, что он работает правильно? Может быть, при делении на ноль программа выдаёт странный ответ? Или при сложении 0.1 + 0.2 получается не 0.3, а 0.30000000000000004? (Спойлер: в C++ так и будет, если не знать, как правильно работать с числами с плавающей точкой).
В реальной жизни мы постоянно проверяем вещи. Когда ты покупаешь новый телефон, ты его включаешь, проверяешь экран, камеру, динамики. Когда повар готовит суп, он пробует его ложкой — не пересолил ли? Когда строитель строит мост, он проверяет каждую балку, каждый болт. Это и есть тестирование.
В программировании тестирование — это процесс проверки того, что код делает именно то, что мы от него ожидаем. И это не просто «запустил и посмотрел». Это целая наука с правилами, инструментами и методиками.
💡 Важное замечание: Тестирование не гарантирует, что в коде нет ошибок. Оно лишь показывает, что в проверенных ситуациях код работает правильно. Как говорил знаменитый учёный Эдсгер Дейкстра: «Тестирование может доказать наличие ошибок, но не их отсутствие».
Зачем же нужно тестирование? Давай перечислим основные причины:
- Экономия времени. Найти ошибку через месяц после написания кода — это кошмар. Ты уже забыл, как работает этот кусок программы, и придётся перечитывать всё заново. Тесты помогают поймать ошибку сразу.
- Уверенность в коде. Когда ты меняешь что-то в программе (добавляешь новую функцию или исправляешь старую), тесты показывают, не сломалось ли что-то ещё.
- Документация. Тесты — это живая документация. Если кто-то другой захочет разобраться в твоём коде, он может посмотреть тесты и понять, как должна работать каждая функция.
- Профессионализм. В серьёзных компаниях код без тестов не принимают. Это как сдавать экзамен без подготовки — можно и сдать, но шансы на успех малы.
Теория: как это работает изнутри
Теперь давай разберёмся, какие виды тестирования существуют и как они устроены «под капотом».
Уровни тестирования
Представь, что ты строишь дом. Сначала ты проверяешь каждый кирпич (модульное тестирование), потом проверяешь, как кирпичи соединяются в стены (интеграционное тестирование), потом проверяешь всю готовую комнату (системное тестирование), и наконец — показываешь дом заказчику (приёмочное тестирование).
В программировании уровни похожи:
| Уровень | Что проверяем | Пример |
|---|---|---|
| Модульное (Unit) | Отдельные функции, классы, методы | Проверяем, что функция сложения правильно складывает 2 и 3 |
| Интеграционное | Взаимодействие между модулями | Проверяем, что функция расчёта зарплаты правильно получает данные из базы |
| Системное | Вся программа целиком | Запускаем игру и проверяем, что все уровни проходимы |
| Приёмочное | Соответствие требованиям заказчика | Проверяем, что программа работает на Windows 10 и 11 |
Виды тестов по способу проверки
Есть два основных подхода: ручное тестирование (человек запускает программу и тыкает кнопки) и автоматизированное (код проверяет код). Нас, как программистов, интересует второе.
Автоматизированные тесты делятся на:
- Позитивные — проверяют, что программа работает в нормальных условиях. Например, калькулятор должен правильно складывать 2 + 2.
- Негативные — проверяют, что программа правильно реагирует на ошибки. Например, калькулятор не должен падать при делении на ноль, а должен выдавать сообщение об ошибке.
- Граничные — проверяют на границах допустимых значений. Например, если функция принимает числа от 0 до 100, нужно проверить 0, 100, -1, 101.
Как работает тестовый фреймворк
В C++ есть несколько библиотек для тестирования. Самая популярная — Google Test (или gtest). Она работает по принципу «утверждений» (assertions). Ты пишешь: «Я утверждаю, что функция sum(2, 3) вернёт 5». Если это правда — тест проходит. Если нет — тест падает, и ты видишь, что пошло не так.
Внутри фреймворк делает примерно следующее:
- Запускает твою функцию с заданными параметрами.
- Сравнивает результат с ожидаемым.
- Если результат совпадает — молча переходит к следующему тесту.
- Если не совпадает — записывает ошибку в отчёт и продолжает тестирование.
Это похоже на экзаменатора, который проверяет твои ответы по ключу. Только экзаменатор — это программа, и она проверяет не студентов, а другие программы.
Синтаксис в C++: все формы и варианты
Давай рассмотрим, как писать тесты на C++ с использованием Google Test. Это самая популярная библиотека, и она установлена почти везде. Если у тебя её нет — не страшно, мы рассмотрим и простой самодельный вариант.
Установка Google Test
Самый простой способ — использовать менеджер пакетов vcpkg или Conan. Но для начала можно просто скачать исходники с GitHub и подключить к проекту. Мы не будем углубляться в установку — это тема отдельной статьи.
Базовая структура теста
В Google Test каждый тест — это макрос TEST(). Он принимает два параметра: имя группы тестов и имя конкретного теста.
#include <gtest/gtest.h>
// Функция, которую будем тестировать
int sum(int a, int b) {
return a + b;
}
// Тест: проверяем, что sum(2, 3) == 5
TEST(SumTest, PositiveNumbers) {
EXPECT_EQ(sum(2, 3), 5); // Ожидаем, что результат равен 5
}
// Тест: проверяем, что sum(-1, 1) == 0
TEST(SumTest, NegativeAndPositive) {
EXPECT_EQ(sum(-1, 1), 0); // Ожидаем 0
}
// Главная функция для запуска всех тестов
int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
Основные макросы утверждений
В Google Test есть два типа утверждений:
- ASSERT_* — если проверка не проходит, тест сразу прекращается.
- EXPECT_* — если проверка не проходит, тест продолжается, но ошибка записывается.
Вот таблица самых полезных макросов:
| Макрос | Что проверяет | Пример |
|---|---|---|
| EXPECT_EQ(a, b) | a == b | EXPECT_EQ(sum(2,2), 4) |
| EXPECT_NE(a, b) | a != b | EXPECT_NE(div(5,2), 3) |
| EXPECT_LT(a, b) | a < b | EXPECT_LT(speed, 100) |
| EXPECT_GT(a, b) | a > b | EXPECT_GT(age, 0) |
| EXPECT_TRUE(condition) | condition == true | EXPECT_TRUE(isEven(4)) |
| EXPECT_FALSE(condition) | condition == false | EXPECT_FALSE(isEven(3)) |
| EXPECT_STREQ(s1, s2) | Строки s1 и s2 равны | EXPECT_STREQ(getName(), "Alice") |
| EXPECT_THROW(statement, exception_type) | Код выбрасывает исключение | EXPECT_THROW(div(5,0), std::runtime_error) |
Тестирование классов
Когда мы тестируем классы, нам часто нужно создавать объекты. Для этого есть специальные макросы SetUp и TearDown, которые выполняются до и после каждого теста.
#include <gtest/gtest.h>
#include <vector>
class MyVector {
private:
std::vector<int> data;
public:
void add(int value) { data.push_back(value); }
int get(int index) { return data.at(index); }
int size() { return data.size(); }
};
// Создаём класс-наследник от ::testing::Test
class MyVectorTest : public ::testing::Test {
protected:
MyVector vec;
// Выполняется перед каждым тестом
void SetUp() override {
vec.add(10);
vec.add(20);
vec.add(30);
}
// Выполняется после каждого теста (необязательно)
void TearDown() override {
// Здесь можно очистить ресурсы
}
};
// Теперь используем TEST_F вместо TEST
TEST_F(MyVectorTest, SizeAfterAdding) {
EXPECT_EQ(vec.size(), 3); // Добавили 3 элемента
}
TEST_F(MyVectorTest, GetElement) {
EXPECT_EQ(vec.get(1), 20); // Второй элемент (индекс 1) равен 20
}
TEST_F(MyVectorTest, OutOfRange) {
EXPECT_THROW(vec.get(10), std::out_of_range); // Ожидаем исключение
}
Самодельный тестовый фреймворк
Если ты не хочешь устанавливать Google Test, можно написать простую тестовую систему самому. Это поможет понять, как всё работает изнутри.
#include <iostream>
#include <string>
#include <cassert>
// Простая функция для тестирования
void test_sum() {
// Проверяем, что sum(2, 3) == 5
int result = sum(2, 3);
if (result != 5) {
std::cerr << "Ошибка в test_sum: ожидали 5, получили " << result << std::endl;
} else {
std::cout << "test_sum пройден" << std::endl;
}
}
// Можно использовать assert для простых проверок
void test_divide() {
assert(divide(10, 2) == 5); // Если не 5 — программа упадёт с ошибкой
std::cout << "test_divide пройден" << std::endl;
}
int main() {
test_sum();
test_divide();
std::cout << "Все тесты пройдены!" << std::endl;
return 0;
}
⚠️ Важно: assert() работает только в debug-режиме. В release-режиме он отключается. Для серьёзных проектов лучше использовать Google Test или Catch2.
Примеры кода — от простого к сложному
Пример 1: Тестирование простой функции
Начнём с самого простого — функция, которая проверяет, является ли число чётным.
#include <gtest/gtest.h>
// Функция: возвращает true, если число чётное
bool isEven(int number) {
return number % 2 == 0;
}
// Тест 1: проверяем чётное число
TEST(IsEvenTest, EvenNumber) {
EXPECT_TRUE(isEven(4)); // 4 — чётное, должно быть true
}
// Тест 2: проверяем нечётное число
TEST(IsEvenTest, OddNumber) {
EXPECT_FALSE(isEven(7)); // 7 — нечётное, должно быть false
}
// Тест 3: проверяем ноль (особый случай)
TEST(IsEvenTest, Zero) {
EXPECT_TRUE(isEven(0)); // 0 — чётное (по определению)
}
// Тест 4: проверяем отрицательное чётное число
TEST(IsEvenTest, NegativeEven) {
EXPECT_TRUE(isEven(-2)); // -2 — чётное
}
int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
Объяснение:
- Мы написали 4 теста для одной функции.
- Каждый тест проверяет отдельный случай: чётное, нечётное, ноль, отрицательное.
- Это называется покрытие тестами — мы стараемся проверить все возможные варианты.
Пример 2: Тестирование функции с граничными значениями
Теперь напишем функцию, которая вычисляет факториал. Факториал определён только для неотрицательных целых чисел.
#include <gtest/gtest.h>
// Функция: вычисляет факториал числа n
// Если n < 0, выбрасывает исключение
int factorial(int n) {
if (n < 0) {
throw std::invalid_argument("Факториал не определён для отрицательных чисел");
}
if (n == 0 || n == 1) {
return 1;
}
int result = 1;
for (int i = 2; i <= n; ++i) {
result *= i;
}
return result;
}
// Тест 1: базовые случаи
TEST(FactorialTest, BaseCases) {
EXPECT_EQ(factorial(0), 1); // 0! = 1
EXPECT_EQ(factorial(1), 1); // 1! = 1
}
// Тест 2: обычные числа
TEST(FactorialTest, NormalNumbers) {
EXPECT_EQ(factorial(5), 120); // 5! = 120
EXPECT_EQ(factorial(10), 3628800); // 10! = 3628800
}
// Тест 3: граничный случай — отрицательное число
TEST(FactorialTest, NegativeNumber) {
EXPECT_THROW(factorial(-1), std::invalid_argument); // Должно выбросить исключение
}
// Тест 4: большое число (проверяем, что не переполняется)
TEST(FactorialTest, LargeNumber) {
// 12! = 479001600 — помещается в int
EXPECT_EQ(factorial(12), 479001600);
// 13! = 6227020800 — уже не помещается в int (переполнение)
// Этот тест покажет, что нужно использовать long long
}
Объяснение:
- Мы проверяем не только правильные ответы, но и реакцию на ошибки.
- EXPECT_THROW проверяет, что функция выбрасывает исключение нужного типа.
- Последний тест показывает проблему: int может хранить только до 2 миллиардов, а 13! уже больше. Это пример того, как тесты помогают найти скрытые проблемы.
Пример 3: Тестирование класса «Банковский счёт»
Теперь посложнее — класс с состоянием.
#include <gtest/gtest.h>
#include <stdexcept>
class BankAccount {
private:
double balance;
public:
BankAccount(double initialBalance) {
if (initialBalance < 0) {
throw std::invalid_argument("Начальный баланс не может быть отрицательным");
}
balance = initialBalance;
}
void deposit(double amount) {
if (amount <= 0) {
throw std::invalid_argument("Сумма депозита должна быть положительной");
}
balance += amount;
}
void withdraw(double amount) {
if (amount <= 0) {
throw std::invalid_argument("Сумма снятия должна быть положительной");
}
if (amount > balance) {
throw std::runtime_error("Недостаточно средств");
}
balance -= amount;
}
double getBalance() const {
return balance;
}
};
// Тесты для класса BankAccount
TEST(BankAccountTest, InitialBalance) {
BankAccount account(1000.0);
EXPECT_DOUBLE_EQ(account.getBalance(), 1000.0); // Проверяем начальный баланс
}
TEST(BankAccountTest, DepositMoney) {
BankAccount account(500.0);
account.deposit(200.0);
EXPECT_DOUBLE_EQ(account.getBalance(), 700.0); // 500 + 200 = 700
}
TEST(BankAccountTest, WithdrawMoney) {
BankAccount account(1000.0);
account.withdraw(300.0);
EXPECT_DOUBLE_EQ(account.getBalance(), 700.0); // 1000 - 300 = 700
}
TEST(BankAccountTest, WithdrawMoreThanBalance) {
BankAccount account(100.0);
EXPECT_THROW(account.withdraw(200.0), std::runtime_error); // Нельзя снять больше, чем есть
}
TEST(BankAccountTest, NegativeDeposit) {
BankAccount account(500.0);
EXPECT_THROW(account.deposit(-50.0), std::invalid_argument); // Нельзя внести отрицательную сумму
}
TEST(BankAccountTest, NegativeInitialBalance) {
EXPECT_THROW(BankAccount account(-100.0), std::invalid_argument); // Нельзя создать счёт с отрицательным балансом
}
TEST(BankAccountTest, MultipleOperations) {
BankAccount account(1000.0);
account.deposit(500.0); // стало 1500
account.withdraw(200.0); // стало 1300
account.deposit(100.0); // стало 1400
EXPECT_DOUBLE_EQ(account.getBalance(), 1400.0);
}
Объяснение:
- Мы тестируем не только отдельные операции, но и их последовательность.
- EXPECT_DOUBLE_EQ используется для сравнения чисел с плавающей точкой (учитывает погрешность).
- Каждый тест создаёт свой объект BankAccount — тесты не зависят друг от друга.
Пример 4: Тестирование работы с файлами
Работа с файлами — частая причина ошибок. Давай напишем тест для функции, которая читает числа из файла и суммирует их.
#include <gtest/gtest.h>
#include <fstream>
#include <vector>
#include <string>
// Функция: читает числа из файла и возвращает их сумму
int sumNumbersFromFile(const std::string& filename) {
std::ifstream file(filename);
if (!file.is_open()) {
throw std::runtime_error("Не удалось открыть файл");
}
int sum = 0;
int number;
while (file >> number) {
sum += number;
}
if (file.bad()) {
throw std::runtime_error("Ошибка при чтении файла");
}
return sum;
}
// Вспомогательная функция: создаёт тестовый файл
void createTestFile(const std::string& filename, const std::vector<int>& numbers) {
std::ofstream file(filename);
for (int num : numbers) {
file << num << " ";
}
}
// Тест 1: обычный файл с числами
TEST(FileSumTest, NormalFile) {
createTestFile("test_normal.txt", {1, 2, 3, 4, 5});
EXPECT_EQ(sumNumbersFromFile("test_normal.txt"), 15); // 1+2+3+4+5 = 15
std::remove("test_normal.txt"); // Удаляем тестовый файл
}
// Тест 2: пустой файл
TEST(FileSumTest, EmptyFile) {
createTestFile("test_empty.txt", {});
EXPECT_EQ(sumNumbersFromFile("test_empty.txt"), 0); // Сумма пустого файла = 0
std::remove("test_empty.txt");
}
// Тест 3: файл с одним числом
TEST(FileSumTest, SingleNumber) {
createTestFile("test_single.txt", {42});
EXPECT_EQ(sumNumbersFromFile("test_single.txt"), 42);
std::remove("test_single.txt");
}
// Тест 4: файл с отрицательными числами
TEST(FileSumTest, NegativeNumbers) {
createTestFile("test_negative.txt", {-5, 10, -3, 8});
EXPECT_EQ(sumNumbersFromFile("test_negative.txt"), 10); // -5+10-3+8 = 10
std::remove("test_negative.txt");
}
// Тест 5: несуществующий файл
TEST(FileSumTest, NonExistentFile) {
EXPECT_THROW(sumNumbersFromFile("nonexistent.txt"), std::runtime_error);
}
Объяснение:
- Мы создаём временные файлы для тестов и удаляем их после проверки.
- Важно: каждый тест создаёт свой файл, чтобы не было конфликтов.
- Мы проверяем не только успешные случаи, но и ошибки (несуществующий файл).
Пример 5: Параметризованные тесты
Иногда нужно проверить одну и ту же функцию с разными входными данными. В Google Test для этого есть параметризованные тесты.
#include <gtest/gtest.h>
#include <cmath>
// Функция: вычисляет площадь круга по радиусу
double circleArea(double radius) {
if (radius < 0) {
throw std::invalid_argument("Радиус не может быть отрицательным");
}
return M_PI * radius * radius;
}
// Создаём класс для параметризованного теста
class CircleAreaTest : public ::testing::TestWithParam<std::tuple<double, double>> {
// Первый элемент кортежа — радиус, второй — ожидаемая площадь
};
// Пишем тест, который использует параметры
TEST_P(CircleAreaTest, CheckArea) {
double radius = std::get<0>(GetParam());
double expectedArea = std::get<1>(GetParam());
double actualArea = circleArea(radius);
EXPECT_NEAR(actualArea, expectedArea, 0.001); // Проверяем с точностью до 0.001
}
// Задаём набор параметров: (радиус, ожидаемая площадь)
INSTANTIATE_TEST_SUITE_P(
CircleAreaTests,
CircleAreaTest,
::testing::Values(
std::make_tuple(0.0, 0.0),
std::make_tuple(1.0, M_PI),
std::make_tuple(2.0, 4 * M_PI),
std::make_tuple(5.0, 25 * M_PI),
std::make_tuple(0.5, M_PI * 0.25)
)
);
// Отдельный тест для отрицательного радиуса
TEST(CircleAreaTest, NegativeRadius) {
EXPECT_THROW(circleArea(-1.0), std::invalid_argument);
}
Объяснение:
- Параметризованные тесты позволяют не писать 5 одинаковых тестов, а задать данные в одном месте.
- Если нужно добавить новый случай — просто дописываем строку в INSTANTIATE_TEST_SUITE_P.
- EXPECT_NEAR проверяет, что числа равны с заданной точностью (полезно для double).
Пример 6: Тестирование с mock-объектами
Иногда функция зависит от внешних сервисов (база данных, интернет, время). Чтобы протестировать такую функцию, используют mock-объекты — «заглушки», которые имитируют поведение реальных объектов.
#include <gtest/gtest.h>
#include <gmock/gmock.h>
// Интерфейс для отправки уведомлений
class NotificationService {
public:
virtual ~NotificationService() = default;
virtual void sendEmail(const std::string& to, const std::string& message) = 0;
};
// Mock-класс (имитация) для NotificationService
class MockNotificationService : public NotificationService {
public:
MOCK_METHOD(void, sendEmail, (const std::string& to, const std::string& message), (override));
};
// Класс, который использует NotificationService
class UserManager {
private:
NotificationService* notificationService;
public:
UserManager(NotificationService* service) : notificationService(service) {}
void registerUser(const std::string& email) {
// Регистрируем пользователя...
// И отправляем уведомление
notificationService->sendEmail(email, "Добро пожаловать!");
}
};
// Тест с использованием mock-объекта
TEST(UserManagerTest, RegisterUserSendsEmail) {
// Создаём mock-объект
MockNotificationService mockService;
// Ожидаем, что sendEmail будет вызван ровно 1 раз с email "test@example.com"
EXPECT_CALL(mockService, sendEmail("test@example.com", "Добро пожаловать!"))
.Times(1);
// Создаём UserManager с mock-объектом
UserManager manager(&mockService);
// Выполняем тестируемое действие
manager.registerUser("test@example.com");
// Если sendEmail не был вызван — тест упадёт
}
Объяснение:
- Mock-объекты позволяют тестировать код, не подключаясь к реальным сервисам.
- Мы проверяем, что функция registerUser вызывает sendEmail с правильными параметрами.
- Google Mock (часть Google Test) автоматически проверяет, что все ожидаемые вызовы были сделаны.
Пример 7: Тестирование производительности
Иногда важно не только чтобы код работал правильно, но и чтобы он работал быстро. Для этого есть бенчмарки (тесты производительности).
#include <gtest/gtest.h>
#include <chrono>
#include <vector>
#include <algorithm>
// Функция: сортирует вектор пузырьком (медленно)
void bubbleSort(std::vector<int>& arr) {
int n = arr.size();
for (int i = 0; i < n - 1; ++i) {
for (int j = 0; j < n - i - 1; ++j) {
if (arr[j] > arr[j + 1]) {
std::swap(arr[j], arr[j + 1]);
}
}
}
}
// Функция: сортирует вектор с помощью std::sort (быстро)
void fastSort(std::vector<int>& arr) {
std::sort(arr.begin(), arr.end());
}
// Тест производительности
TEST(SortPerformanceTest, BubbleSortVsFastSort) {
const int SIZE = 10000;
// Создаём два одинаковых вектора
std::vector<int> data1(SIZE);
std::vector<int> data2(SIZE);
// Заполняем случайными числами
for (int i = 0; i < SIZE; ++i) {
int value = rand() % 10000;
data1[i] = value;
data2[i] = value;
}
// Измеряем время bubbleSort
auto start1 = std::chrono::high_resolution_clock::now();
bubbleSort(data1);
auto end1 = std::chrono::high_resolution_clock::now();
auto duration1 = std::chrono::duration_cast<std::chrono::milliseconds>(end1 - start1);
// Измеряем время fastSort
auto start2 = std::chrono::high_resolution_clock::now();
fastSort(data2);
auto end2 = std::chrono::high_resolution_clock::now();
auto duration2 = std::chrono::duration_cast<std::chrono::milliseconds>(end2 - start2);
// Выводим результаты
std::cout << "Bubble sort time: " << duration1.count() << " ms" << std::endl;
std::cout << "Fast sort time: " << duration2.count() << " ms" << std::endl;
// Проверяем, что fastSort быстрее хотя бы в 10 раз
EXPECT_GT(duration1.count(), duration2.count() * 10);
// Проверяем, что оба вектора отсортированы одинаково
EXPECT_EQ(data1, data2);
}
Объяснение: