doctest для C++: полное руководство с примерами
1. Что это и зачем нужна?
doctest — это легковесный, самодокументируемый фреймворк для модульного тестирования на языке C++. Его главная особенность — минимальное время компиляции и простота использования. В отличие от тяжеловесных фреймворков, таких как Google Test или Boost.Test, doctest представляет собой заголовочную библиотеку (header-only), которую можно подключить одной строкой #include <doctest/doctest.h>. Это делает её идеальным выбором для проектов, где важна скорость сборки и отсутствие внешних зависимостей.
Зачем нужен doctest? В современной разработке на C++ тестирование кода — это не роскошь, а необходимость. doctest позволяет писать тесты прямо рядом с реализацией функций, что улучшает читаемость кода и облегчает его поддержку. Вы можете встраивать тесты в заголовочные файлы, и они будут автоматически исключены из финальной сборки при компиляции без флага -DDOCTEST_CONFIG_DISABLE. Это даёт уникальную возможность: тесты живут вместе с кодом, не влияя на производительность релизной версии.
doctest поддерживает все современные стандарты C++ (C++11 и выше), работает на всех основных платформах (Windows, Linux, macOS) и легко интегрируется с системами непрерывной интеграции (CI). Он предоставляет богатый набор макросов для проверки утверждений, группировки тестов, работы с исключениями и генерации отчётов. Благодаря своей лёгкости, doctest часто используется в embedded-разработке, игровых движках и других проектах, где каждый килобайт и миллисекунда компиляции на счету.
2. Установка
doctest — это header-only библиотека, поэтому установка сводится к копированию заголовочного файла в ваш проект. Самый простой способ — использовать менеджер пакетов vcpkg или Conan, либо скачать файл вручную.
Вариант 1: Установка через vcpkg (рекомендуется)
# Установите vcpkg, если ещё не установили:
git clone https://github.com/Microsoft/vcpkg.git
cd vcpkg
./bootstrap-vcpkg.sh # Linux/macOS
# или .\bootstrap-vcpkg.bat (Windows)
# Установите doctest:
./vcpkg install doctest
# Подключите к проекту через CMake:
# find_package(doctest CONFIG REQUIRED)
# target_link_libraries(your_target PRIVATE doctest::doctest)
Вариант 2: Установка через Conan
# Добавьте в conanfile.txt:
[requires]
doctest/2.4.11
[generators]
cmake
# Установите:
conan install . --build=missing
Вариант 3: Ручная установка (просто скопировать файл)
# Скачайте последнюю версию с GitHub:
wget https://raw.githubusercontent.com/doctest/doctest/master/doctest/doctest.h
# Поместите файл в папку include вашего проекта
# Затем в коде просто используйте:
# #include "doctest.h"
После установки убедитесь, что компилятор может найти заголовочный файл. Для компиляции тестов достаточно любой современной версии GCC, Clang или MSVC.
3. Быстрый старт — минимальный рабочий пример
Создайте файл test_example.cpp со следующим содержимым:
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
TEST_CASE("testing the factorial function") {
CHECK(factorial(0) == 1);
CHECK(factorial(1) == 1);
CHECK(factorial(5) == 120);
CHECK(factorial(10) == 3628800);
}
Скомпилируйте и запустите:
g++ -std=c++11 test_example.cpp -o test_example
./test_example
Вы увидите вывод:
===========================================================================
[doctest] test cases: 1 | 1 passed | 0 failed | 0 skipped
[doctest] assertions: 4 | 4 passed | 0 failed |
[doctest] Status: SUCCESS!
Поздравляю! Вы написали и запустили свой первый тест с doctest. Обратите внимание на макрос DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN — он создаёт точку входа main() для тестов. Если вы хотите написать свой main(), используйте DOCTEST_CONFIG_IMPLEMENT и вызовите doctest::Context вручную.
4. Основные методы/функции/классы
doctest предоставляет мощный набор макросов и классов. Ниже приведены 8 ключевых элементов с сигнатурами, описанием и примерами.
4.1. TEST_CASE — определение тестового случая
Сигнатура: TEST_CASE(const char* name)
Описание: Макрос для определения тестового случая. Все проверки внутри него выполняются как единый тест. Имя теста должно быть уникальным в пределах файла.
TEST_CASE("vector addition works correctly") {
std::vector<int> a = {1, 2, 3};
std::vector<int> b = {4, 5, 6};
std::vector<int> result;
for (size_t i = 0; i < a.size(); ++i) {
result.push_back(a[i] + b[i]);
}
CHECK(result[0] == 5);
CHECK(result[1] == 7);
CHECK(result[2] == 9);
}
4.2. CHECK — проверка утверждения (без остановки)
Сигнатура: CHECK(expression)
Описание: Проверяет, что выражение истинно. Если проверка не проходит, тест продолжает выполняться (в отличие от REQUIRE). Выводит подробную информацию об ошибке.
TEST_CASE("CHECK does not stop on failure") {
CHECK(1 == 2); // Эта проверка не пройдёт
CHECK(2 == 2); // Но эта всё равно выполнится
// В выводе будет два assertion: один failed, один passed
}
4.3. REQUIRE — критическая проверка (с остановкой)
Сигнатура: REQUIRE(expression)
Описание: Проверяет, что выражение истинно. Если проверка не проходит, выполнение текущего тестового случая немедленно прекращается. Используется для критических предусловий.
TEST_CASE("REQUIRE stops on failure") {
int* ptr = nullptr;
REQUIRE(ptr != nullptr); // Тест остановится здесь
*ptr = 42; // Этот код не выполнится
}
4.4. CHECK_FALSE — проверка ложности
Сигнатура: CHECK_FALSE(expression)
Описание: Проверяет, что выражение ложно. Аналог CHECK(!expression), но с более понятным сообщением об ошибке.
TEST_CASE("CHECK_FALSE example") {
bool is_empty = true;
CHECK_FALSE(is_empty); // Не пройдёт, т.к. is_empty == true
}
4.5. CHECK_THROWS — проверка исключения
Сигнатура: CHECK_THROWS(expression)
Описание: Проверяет, что выражение выбрасывает любое исключение. Если исключение не выброшено, тест считается проваленным.
void might_throw(bool should_throw) {
if (should_throw) {
throw std::runtime_error("error");
}
}
TEST_CASE("CHECK_THROWS example") {
CHECK_THROWS(might_throw(true)); // Пройдёт
CHECK_THROWS(might_throw(false)); // Не пройдёт
}
4.6. CHECK_THROWS_AS — проверка типа исключения
Сигнатура: CHECK_THROWS_AS(expression, exception_type)
Описание: Проверяет, что выражение выбрасывает исключение конкретного типа. Позволяет убедиться, что выбрасывается именно ожидаемое исключение, а не любое.
void divide_by_zero() {
throw std::invalid_argument("division by zero");
}
TEST_CASE("CHECK_THROWS_AS example") {
CHECK_THROWS_AS(divide_by_zero(), std::invalid_argument); // Пройдёт
CHECK_THROWS_AS(divide_by_zero(), std::runtime_error); // Не пройдёт
}
4.7. SUBCASE — вложенные тестовые сценарии
Сигнатура: SUBCASE(const char* name)
Описание: Позволяет создавать вложенные сценарии внутри одного TEST_CASE. Каждый SUBCASE выполняется независимо, начиная с начала TEST_CASE. Идеально для тестирования с разными входными данными без дублирования кода.
TEST_CASE("string operations") {
std::string s = "hello";
SUBCASE("length") {
CHECK(s.length() == 5);
}
SUBCASE("uppercase") {
std::transform(s.begin(), s.end(), s.begin(), ::toupper);
CHECK(s == "HELLO");
}
SUBCASE("substring") {
CHECK(s.substr(0, 2) == "he");
}
}
4.8. INFO — добавление контекстной информации
Сигнатура: INFO(expression)
Описание: Добавляет дополнительную информацию к сообщению об ошибке. Полезно для отладки, когда нужно понять, при каких данных произошёл сбой. Информация выводится только при провале проверки.
TEST_CASE("INFO example") {
std::vector<int> data = {1, 2, 3, 4, 5};
for (size_t i = 0; i < data.size(); ++i) {
INFO("Index: ", i, ", Value: ", data[i]);
CHECK(data[i] > 0); // Если проверка не пройдёт, увидим индекс и значение
}
}
5. Реальный практический пример
Рассмотрим реальный сценарий: разработка класса BankAccount для банковской системы. Мы напишем тесты, покрывающие основные операции: создание счёта, пополнение, снятие, перевод и обработку ошибок.
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"
#include <string>
#include <stdexcept>
#include <vector>
// Класс банковского счёта
class BankAccount {
private:
std::string owner_name;
double balance;
std::string account_number;
static int next_account_number;
public:
BankAccount(const std::string& name, double initial_balance = 0.0)
: owner_name(name), balance(initial_balance) {
if (initial_balance < 0) {
throw std::invalid_argument("Initial balance cannot be negative");
}
if (name.empty()) {
throw std::invalid_argument("Owner name cannot be empty");
}
account_number = "ACC" + std::to_string(next_account_number++);
}
void deposit(double amount) {
if (amount <= 0) {
throw std::invalid_argument("Deposit amount must be positive");
}
balance += amount;
}
void withdraw(double amount) {
if (amount <= 0) {
throw std::invalid_argument("Withdrawal amount must be positive");
}
if (amount > balance) {
throw std::runtime_error("Insufficient funds");
}
balance -= amount;
}
double get_balance() const { return balance; }
std::string get_owner() const { return owner_name; }
std::string get_account_number() const { return account_number; }
static bool transfer(BankAccount& from, BankAccount& to, double amount) {
if (amount <= 0) {
throw std::invalid_argument("Transfer amount must be positive");
}
if (from.balance < amount) {
return false; // Недостаточно средств
}
from.withdraw(amount);
to.deposit(amount);
return true;
}
};
int BankAccount::next_account_number = 1000;
// ===================== ТЕСТЫ =====================
TEST_CASE("BankAccount creation") {
SUBCASE("Create account with valid data") {
BankAccount acc("Alice", 1000.0);
CHECK(acc.get_owner() == "Alice");
CHECK(acc.get_balance() == 1000.0);
CHECK(!acc.get_account_number().empty());
}
SUBCASE("Create account with zero balance") {
BankAccount acc("Bob");
CHECK(acc.get_balance() == 0.0);
}
SUBCASE("Create account with negative balance throws") {
CHECK_THROWS_AS(BankAccount("Charlie", -100.0), std::invalid_argument);
}
SUBCASE("Create account with empty name throws") {
CHECK_THROWS_AS(BankAccount("", 500.0), std::invalid_argument);
}
}
TEST_CASE("BankAccount deposit") {
BankAccount acc("Dave", 500.0);
SUBCASE("Deposit positive amount") {
acc.deposit(200.0);
CHECK(acc.get_balance() == 700.0);
}
SUBCASE("Deposit zero amount throws") {
CHECK_THROWS_AS(acc.deposit(0.0), std::invalid_argument);
}
SUBCASE("Deposit negative amount throws") {
CHECK_THROWS_AS(acc.deposit(-50.0), std::invalid_argument);
}
SUBCASE("Multiple deposits") {
acc.d