⚙️ C++

Тестирование кода в 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». Если это правда — тест проходит. Если нет — тест падает, и ты видишь, что пошло не так.

Внутри фреймворк делает примерно следующее:

  1. Запускает твою функцию с заданными параметрами.
  2. Сравнивает результат с ожидаемым.
  3. Если результат совпадает — молча переходит к следующему тесту.
  4. Если не совпадает — записывает ошибку в отчёт и продолжает тестирование.

Это похоже на экзаменатора, который проверяет твои ответы по ключу. Только экзаменатор — это программа, и она проверяет не студентов, а другие программы.

Синтаксис в 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);
}

Объяснение: