Продвинутое тестирование C++: от юнит-тестов до end-to-end
В современной C++ разработке качество кода определяется не только его производительностью, но и надёжностью. Продвинутое тестирование выходит далеко за рамки простых проверок — это архитектурный подход, включающий юнит-тесты, интеграционные и end-to-end (e2e) тесты. В этой статье мы разберём, как правильно выстроить пирамиду тестирования для C++ проектов, используя Google Test, Google Mock и собственные фреймворки.
1. Юнит-тестирование: изоляция и моки
Юнит-тесты проверяют отдельные модули (функции, классы) в изоляции. На продвинутом уровне ключевая задача — научиться правильно mock-ить зависимости. Рассмотрим пример с использованием Google Mock.
Допустим, у нас есть класс DatabaseConnector, который зависит от внешнего API. Мы хотим протестировать логику обработки данных, не обращаясь к реальной базе.
#include <gmock/gmock.h>
#include <gtest/gtest.h>
class DatabaseInterface {
public:
virtual ~DatabaseInterface() = default;
virtual std::string fetchData(int id) = 0;
};
class MockDatabase : public DatabaseInterface {
public:
MOCK_METHOD(std::string, fetchData, (int id), (override));
};
class DataProcessor {
DatabaseInterface& db;
public:
explicit DataProcessor(DatabaseInterface& db) : db(db) {}
std::string process(int id) {
auto raw = db.fetchData(id);
if (raw.empty()) return "EMPTY";
return "PROCESSED: " + raw;
}
};
TEST(DataProcessorTest, ProcessWithValidData) {
MockDatabase mockDb;
EXPECT_CALL(mockDb, fetchData(42))
.WillOnce(testing::Return("Hello"));
DataProcessor processor(mockDb);
EXPECT_EQ(processor.process(42), "PROCESSED: Hello");
}
Использование MOCK_METHOD позволяет точно контролировать поведение зависимостей. Важно помнить про strict mocks (через NiceMock или StrictMock) для отлова неожиданных вызовов.
2. Интеграционное тестирование: тестирование взаимодействий
Интеграционные тесты проверяют, как модули работают вместе. В C++ это часто включает тестирование работы с файловой системой, сетевыми протоколами или базами данных. Продвинутый подход — использовать test fixtures и parameterized tests.
Пример: тестируем класс FileManager, который читает и пишет в файлы. Используем временные директории.
class FileManagerTest : public ::testing::Test {
protected:
std::filesystem::path tempDir;
void SetUp() override {
tempDir = std::filesystem::temp_directory_path() / "test_files";
std::filesystem::create_directories(tempDir);
}
void TearDown() override {
std::filesystem::remove_all(tempDir);
}
};
TEST_F(FileManagerTest, WriteAndReadFile) {
FileManager fm(tempDir.string());
fm.write("test.txt", "data");
auto content = fm.read("test.txt");
EXPECT_EQ(content, "data");
}
Для параметризованных тестов используем TEST_P с INSTANTIATE_TEST_SUITE_P. Это позволяет прогнать один и тот же тест с разными входными данными.
class MathTest : public ::testing::TestWithParam<std::tuple<int, int, int>> {};
TEST_P(MathTest, Add) {
auto [a, b, expected] = GetParam();
EXPECT_EQ(add(a, b), expected);
}
INSTANTIATE_TEST_SUITE_P(AddTests, MathTest,
::testing::Values(
std::make_tuple(1, 1, 2),
std::make_tuple(-1, 1, 0),
std::make_tuple(0, 0, 0)
));
3. End-to-End тестирование: проверка полного цикла
E2e тесты имитируют поведение конечного пользователя. Для C++ приложений это может означать запуск полного бинарника, проверку его вывода или взаимодействие через API. Продвинутый уровень — автоматизация через CI/CD и контейнеризация.
Пример: тестируем консольное приложение, которое обрабатывает аргументы командной строки.
#include <cstdlib>
#include <string>
int runApp(const std::string& args) {
std::string cmd = "./myapp " + args;
return std::system(cmd.c_str());
}
TEST(E2ETest, AppReturnsZeroOnValidInput) {
int exitCode = runApp("--input data.txt --output result.txt");
EXPECT_EQ(exitCode, 0);
// Дополнительно проверяем содержимое result.txt
std::ifstream f("result.txt");
std::string content((std::istreambuf_iterator<char>(f)), {});
EXPECT_FALSE(content.empty());
}
Для сложных приложений (например, серверов) используйте Docker Compose для поднятия инфраструктуры. В CI (GitHub Actions, GitLab CI) запускайте e2e тесты отдельным шагом, чтобы не замедлять быстрые unit-тесты.
4. Продвинутые техники: code coverage, property-based testing
Не ограничивайтесь стандартными подходами. Рассмотрим две мощные техники:
- Code Coverage с
gcovиlcov. Собирайте отчёты после каждого прогона тестов. Стремитесь к 80%+ покрытия, но помните: не всё, что покрыто, — протестировано качественно. - Property-based testing с библиотекой
rapidcheck. Вместо конкретных значений вы описываете свойства, которые должны выполняться для любых входных данных.
#include <rapidcheck.h>
#include <rapidcheck/gtest.h>
RC_GTEST_PROP(ReverseTest, ReverseOfReverseIsOriginal,
(const std::vector<int>& vec)) {
auto reversed = vec;
std::reverse(reversed.begin(), reversed.end());
std::reverse(reversed.begin(), reversed.end());
RC_ASSERT(reversed == vec);
}
Такой подход отлавливает редкие граничные случаи, которые вы могли упустить.
Заключение
Продвинутое тестирование в C++ — это не просто запуск тестов, а системный подход: от изолированных юнит-тестов с моками до полноценных e2e сценариев в CI. Используйте Google Test и Google Mock для структурирования, параметризованные тесты для покрытия, а property-based testing для поиска скрытых багов.