Тестирование кода в C# (.NET): подробное руководство
Что это такое и зачем? (аналогия из реальной жизни)
Представь, что ты печешь пирог по новому рецепту. Ты смешиваешь муку, яйца, сахар, ставишь в духовку. Но прежде чем подать этот пирог гостям, ты обязательно его попробуешь: не пересолен ли? Пропекся ли внутри? Не подгорел ли снизу? Этот процесс «пробы» и есть тестирование.
В программировании всё точно так же. Когда ты пишешь код, ты создаешь «рецепт» для компьютера. Но компьютер — очень буквальный «повар». Если в рецепте написано «добавить соли», а ты забыл указать количество, компьютер может добавить всю пачку. Или если ты написал «перемешать 10 раз», а нужно было «взбить миксером», пирог не получится.
Тестирование кода — это способ проверить, что твоя программа работает именно так, как ты задумал, и не «ломается» в неожиданных ситуациях. Это как страховка: ты можешь быть уверен, что после изменения одной части программы, другая часть не перестанет работать.
«Код без тестов — это не программа, а просто набор символов, который, возможно, когда-то работал». — народная мудрость программистов.
Зачем это нужно школьнику или новичку? Представь, что ты написал игру «Угадай число». Ты запустил её, поиграл — всё работает. Но через неделю ты решил добавить подсчёт очков. Изменил код, запустил — игра работает, но теперь число угадывается не с 1 до 100, а с 1 до 50. Ты случайно изменил строчку. Если бы у тебя были тесты, они бы сразу сказали: «Ошибка! Игра должна загадывать числа до 100, а загадывает до 50!»
Тестирование экономит часы, дни и нервы. Профессиональные программисты тратят на тесты до 40% своего времени. И это не потому, что они не умеют писать код, а потому что они хотят спать спокойно, зная, что их программа не упадет в самый ответственный момент.
Теория: как это работает изнутри
В мире .NET (C#) тестирование делится на несколько уровней. Представь себе матрёшку:
- Модульное тестирование (Unit Testing) — проверка самой маленькой части программы: одного метода, одной функции. Как проверка одного яйца на свежесть перед тем, как разбить его в тесто.
- Интеграционное тестирование — проверка, как разные части программы работают вместе. Как проверка, что мука и яйца действительно смешиваются в тесто, а не рассыпаются по столу.
- Функциональное тестирование (E2E) — проверка программы целиком, как её видит пользователь. Как дегустация готового пирога.
В этой статье мы сосредоточимся на модульном тестировании, потому что это основа основ. Его пишут сами разработчики, и оно выполняется автоматически за доли секунды.
Как это работает изнутри?
У тебя есть класс Calculator с методом Add(int a, int b), который возвращает сумму. Ты пишешь тест:
- Создаёшь экземпляр класса
Calculator. - Вызываешь метод
Add(2, 3). - Проверяешь: результат равен 5?
Если результат не 5 — тест падает (красный). Если 5 — тест проходит (зелёный). Всё просто.
В C# для этого существует специальный фреймворк — xUnit (или NUnit, MSTest). Это библиотека, которая даёт тебе инструменты: атрибуты для обозначения тестов, классы для проверки результатов (Assert).
Сам процесс выглядит так:
- Ты создаёшь отдельный проект в решении (Solution) — например,
MyApp.Tests. - Добавляешь ссылку на основной проект (
MyApp). - Пишешь классы с тестами.
- Запускаешь тесты через специальное окно в Visual Studio или через консоль командой
dotnet test.
Важно: тесты — это тоже код. Их нужно писать так же аккуратно, как и основной код. Грязные тесты хуже, чем отсутствие тестов, потому что они создают ложное чувство безопасности.
Синтаксис в C# (.NET): все формы и варианты
В C# есть три основных фреймворка для тестирования: MSTest (от Microsoft), NUnit (старый, но популярный) и xUnit (современный стандарт). Мы будем использовать xUnit, так как он рекомендуется Microsoft для новых проектов.
Основные атрибуты (метки) в xUnit
| Атрибут | Назначение | Пример |
|---|---|---|
[Fact] |
Помечает метод как тест без параметров. Самый простой тест. | [Fact] public void Add_TwoNumbers_ReturnsSum() |
[Theory] |
Помечает метод как тест с параметрами. Позволяет запустить один тест с разными данными. | [Theory] [InlineData(1,2,3)] |
[InlineData] |
Предоставляет данные для [Theory]. Можно использовать несколько раз. |
[InlineData(1, 2, 3)] [InlineData(0, 0, 0)] |
[Trait] |
Позволяет категоризировать тесты (например, "Быстрые", "Медленные"). | [Trait("Category", "Unit")] |
Основные методы проверки (Assert)
| Метод | Что проверяет | Пример |
|---|---|---|
Assert.Equal(expected, actual) |
Равенство двух значений | Assert.Equal(5, result); |
Assert.NotEqual(expected, actual) |
Неравенство | Assert.NotEqual(10, result); |
Assert.True(condition) |
Условие истинно | Assert.True(result > 0); |
Assert.False(condition) |
Условие ложно | Assert.False(result < 0); |
Assert.Null(object) |
Объект равен null | Assert.Null(user.Name); |
Assert.NotNull(object) |
Объект не равен null | Assert.NotNull(user); |
Assert.Throws |
Метод выбрасывает исключение | Assert.Throws |
Assert.Contains(expected, collection) |
Коллекция содержит элемент | Assert.Contains("Москва", cities); |
Assert.DoesNotContain(expected, collection) |
Коллекция не содержит элемент | Assert.DoesNotContain("Лондон", cities); |
Структура тестового проекта
Обычно проект называется ИмяОсновногоПроекта.Tests. Внутри — классы, которые зеркалят классы основного проекта. Например:
// Основной проект: MyApp
namespace MyApp
{
public class Calculator
{
public int Add(int a, int b) => a + b;
}
}
// Тестовый проект: MyApp.Tests
using Xunit;
using MyApp; // Подключаем основной проект
namespace MyApp.Tests
{
public class CalculatorTests
{
[Fact] // Атрибут, который говорит: "Это тест!"
public void Add_TwoPositiveNumbers_ReturnsCorrectSum()
{
// Arrange (Подготовка) - создаём всё, что нужно для теста
var calculator = new Calculator();
int a = 5;
int b = 3;
// Act (Действие) - выполняем метод, который тестируем
int result = calculator.Add(a, b);
// Assert (Проверка) - сверяем результат с ожиданием
Assert.Equal(8, result);
}
}
}
Обрати внимание на названия методов: ИмяМетода_Ситуация_ОжидаемыйРезультат. Это стандарт, который делает тесты читаемыми.
Примеры кода — от простого к сложному
Пример 1: Простейший тест калькулятора
using Xunit;
namespace MyApp.Tests
{
public class SimpleCalculatorTests
{
[Fact]
public void Add_OnePlusTwo_ReturnsThree()
{
// Arrange
var calc = new SimpleCalculator();
// Act
int sum = calc.Add(1, 2);
// Assert
Assert.Equal(3, sum); // Проверяем, что 1 + 2 = 3
}
}
// Класс, который мы тестируем
public class SimpleCalculator
{
public int Add(int x, int y) => x + y;
}
}
Объяснение: Мы создали класс SimpleCalculator с одним методом Add. Тест проверяет, что 1 + 2 действительно равно 3. Если кто-то случайно изменит метод Add на x - y, тест упадёт.
Пример 2: Тест с разными данными (Theory)
using Xunit;
namespace MyApp.Tests
{
public class CalculatorTheoryTests
{
[Theory] // Говорим, что это тест с параметрами
[InlineData(1, 2, 3)] // Первый набор: 1 + 2 = 3
[InlineData(-1, -1, -2)] // Второй набор: -1 + -1 = -2
[InlineData(0, 0, 0)] // Третий набор: 0 + 0 = 0
[InlineData(100, -50, 50)] // Четвертый набор: 100 + (-50) = 50
public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
{
// Arrange
var calc = new SimpleCalculator();
// Act
int result = calc.Add(a, b);
// Assert
Assert.Equal(expected, result);
}
}
}
Объяснение: Вместо того чтобы писать 4 отдельных теста, мы написали один, но с 4 наборами данных. xUnit сам запустит этот тест 4 раза с разными параметрами. Это экономит время и делает код чище.
Пример 3: Тест на исключение (ошибку)
using Xunit;
using System;
namespace MyApp.Tests
{
public class DivisionTests
{
[Fact]
public void Divide_ByZero_ThrowsDivideByZeroException()
{
// Arrange
var calc = new AdvancedCalculator();
// Act & Assert (объединено)
// Проверяем, что при делении на 0 выбрасывается исключение
Assert.Throws(() => calc.Divide(10, 0));
}
}
public class AdvancedCalculator
{
public int Divide(int a, int b)
{
if (b == 0)
throw new DivideByZeroException("Нельзя делить на ноль!");
return a / b;
}
}
}
Объяснение: Иногда программа должна не просто вернуть результат, а сообщить об ошибке. В C# для этого используются исключения. Тест проверяет, что при делении на ноль программа действительно «кричит» об ошибке, а не делает вид, что всё хорошо.
Пример 4: Тест строк и коллекций
using Xunit;
using System.Collections.Generic;
namespace MyApp.Tests
{
public class StringAndCollectionTests
{
[Fact]
public void Greeting_WithName_ReturnsHelloName()
{
// Arrange
var greeter = new Greeter();
// Act
string message = greeter.SayHello("Анна");
// Assert
Assert.Contains("Анна", message); // Проверяем, что имя есть в строке
Assert.StartsWith("Привет", message); // Проверяем начало строки
}
[Fact]
public void GetCities_ReturnsListWithMoscow()
{
// Arrange
var cityService = new CityService();
// Act
List cities = cityService.GetRussianCities();
// Assert
Assert.Contains("Москва", cities); // Проверяем, что "Москва" есть в списке
Assert.DoesNotContain("Лондон", cities); // Проверяем, что "Лондона" нет
Assert.NotEmpty(cities); // Проверяем, что список не пустой
}
}
public class Greeter
{
public string SayHello(string name) => $"Привет, {name}!";
}
public class CityService
{
public List GetRussianCities() => new List { "Москва", "Санкт-Петербург", "Казань" };
}
}
Объяснение: Тесты не ограничиваются числами. Мы можем проверять строки (содержит ли фраза нужное слово) и коллекции (есть ли элемент в списке).
Пример 5: Тест с мок-объектами (имитация базы данных)
using Xunit;
using Moq; // Библиотека для создания "заглушек"
using System;
namespace MyApp.Tests
{
public class UserServiceTests
{
[Fact]
public void GetUserById_UserExists_ReturnsUser()
{
// Arrange
var mockRepo = new Mock(); // Создаём "фальшивый" репозиторий
// Настраиваем, что при вызове GetById(1) вернётся новый пользователь
mockRepo.Setup(repo => repo.GetById(1))
.Returns(new User { Id = 1, Name = "Иван" });
var service = new UserService(mockRepo.Object); // Передаём заглушку в сервис
// Act
var user = service.GetUserById(1);
// Assert
Assert.NotNull(user);
Assert.Equal("Иван", user.Name);
}
}
// Интерфейс, который имитирует базу данных
public interface IUserRepository
{
User GetById(int id);
}
public class User
{
public int Id { get; set; }
public string Name { get; set; }
}
public class UserService
{
private readonly IUserRepository _repository;
public UserService(IUserRepository repository)
{
_repository = repository;
}
public User GetUserById(int id)
{
var user = _repository.GetById(id);
if (user == null)
throw new Exception("Пользователь не найден");
return user;
}
}
}
Объяснение: В реальных проектах код часто работает с базой данных, файлами или интернетом. Чтобы тестировать такой код, не подключаясь к реальной базе (это медленно и ненадёжно), используют моки (заглушки). Библиотека Moq позволяет создать объект, который ведёт себя как база данных, но на самом деле просто возвращает то, что мы ему сказали.
Пример 6: Тест асинхронного кода
using Xunit;
using System.Threading.Tasks;
namespace MyApp.Tests
{
public class AsyncTests
{
[Fact]
public async Task FetchDataAsync_ReturnsData()
{
// Arrange
var loader = new DataLoader();
// Act
string data = await loader.LoadDataAsync();
// Assert
Assert.False(string.IsNullOrEmpty(data)); // Данные не должны быть пустыми
}
}
public class DataLoader
{
public async Task LoadDataAsync()
{
// Имитация загрузки данных из интернета
await Task.Delay(100); // Ждём 100 миллисекунд
return "Данные загружены";
}
}
}
Объяснение: Современные программы часто работают асинхронно (не ждут, пока загрузится файл, а делают другие дела). Тесты тоже могут быть асинхронными. Обрати внимание на async Task вместо void.
Пример 7: Тест с проверкой граничных значений
using Xunit;
namespace MyApp.Tests
{
public class AgeValidatorTests
{
[Theory]
[InlineData(0)] // Граница: 0 лет
[InlineData(1)] // Минимальный возраст
[InlineData(17)] // Ещё несовершеннолетний
[InlineData(18)] // Граница совершеннолетия
[InlineData(120)] // Максимальный разумный возраст
[InlineData(121)] // За пределами
public void IsAdult_AgeInput_ReturnsExpectedResult(int age)
{
// Arrange
var validator = new AgeValidator();
// Act
bool isAdult = validator.IsAdult(age);
// Assert
if (age >= 18 && age <= 120)
Assert.True(isAdult, $"Возраст {age} должен считаться взрослым");
else
Assert.False(isAdult, $"Возраст {age} не должен считаться взрослым");
}
}
public class AgeValidator
{
public bool IsAdult(int age)
{
if (age < 0 || age > 120)
return false;
return age >= 18;
}
}
}
Объяснение: Ошибки чаще всего возникают на границах допустимых значений. Этот тест проверяет, что программа правильно обрабатывает возраст 0, 1, 17, 18, 120 и 121 год.
Сравнение с Python: чем похоже и чем отличается
| Характеристика | C# (.NET) с xUnit | Python с pytest |
|---|---|---|
| Фреймворк | xUnit, NUnit, MSTest | pytest, unittest (встроенный) |
| Атрибуты/Декораторы | [Fact], [Theory] |
@pytest.mark.parametrize, @pytest.fixture |
| Проверки (Assert) | Assert.Equal(), Assert.True() |
assert result == 5 (просто assert) |
| Типизация | Строгая (int, string, bool) | Динамическая (можно менять тип переменной) |
| Скорость выполнения | Очень быстрая (компилируемый язык) | Медленнее (интерпретируемый язык) |
| Синтаксис лямбда | () => { } |
lambda: ... |
| Моки | Moq, NSubstitute (требуют установки) | unittest.mock (встроенный), pytest-mock |
| Запуск | dotnet test или через IDE |
pytest или python -m pytest |
Главное отличие: В C# тесты — это отдельный проект, который компилируется. В Python тесты — это просто файлы с функциями. C# требует больше «формальностей» (атрибуты, классы), но зато даёт более строгую структуру. Python проще для быстрого старта, но в больших проектах может привести к хаосу.
Совет: Если ты новичок, начни с Python — он проще. Если хочешь стать профессиональным разработчиком под Windows или игры — учи C#.
Типичные ошибки и подводные камни
-
Тест ничего не проверяет
Самая частая ошибка новичков — написать тест, который всегда проходит. Например:
[Fact] public void Test() { var calc = new Calculator(); calc.Add(1, 2); // Забыли Assert! // Тест пройдёт, даже если Add работает неправильно }Решение: Всегда добавляй
Assert. Если тест не содержит проверки — он бесполезен. -
Тест зависит от других тестов
Тесты должны быть независимыми. Если один тест меняет глобальную переменную, а второй от неё зависит — это бомба замедленного действия.
Решение: Каждый тест должен сам создавать все нужные данные (Arrange) и не полагаться на порядок запуска.
-
Тестирование приватных методов
Новички часто пытаются тестировать приватные (скрытые) методы. Это неправильно. Тестировать нужно публичное поведение класса.
Решение: Если приватный метод настолько сложен, что требует тестов, вынеси его в отдельный публичный класс.
-
Слишком много тестов на один метод
Не нужно писать 100 тестов на метод сложения. Достаточно проверить основные случаи: положительные числа, отрицательные, ноль, граничные значения.
Решение: Используй
[Theory]с[InlineData], чтобы сократить количество кода. -
Игнорирование тестов, которые падают
Если тест упал, нельзя просто поставить
[Fact(Skip = "Потом починю")]. Это путь к тому, что все тесты перестанут что-либо значить.Решение: Падающий тест — это красный флаг. Либо чини код, либо удаляй тест, если он больше не актуален.
-
Тестирование кода, который работает с внешними ресурсами
Не тестируй реальную базу данных, файловую систему или интернет в модульных тестах. Это медленно и ненадёжно.
Решение: Используй моки (Moq) или создавай тестовые базы данных в памяти.
Лучшие практики и советы профессионалов
-
Правило трёх A (AAA)
Каждый тест должен состоять из трёх частей: Arrange (подготовка), Act (действие), Assert (проверка). Разделяй их пустыми строками для читаемости.
-
Один тест — одна проверка
Старайся проверять только одну вещь в одном тесте. Если тест упал, ты сразу поймёшь, что именно сломалось.
-
Пиши тесты до кода (TDD — Test Driven Development)
Сначала напиши тест, который падает, потом напиши код, который заставит тест пройти. Это звучит странно, но это дисциплинирует и заставляет думать о том, как будет использоваться твой код.
-
Используй осмысленные имена
Название теста должно описывать, что он проверяет. Плохо:
Test1(). Хорошо:Deposit_NegativeAmount_ThrowsException(). -
Не тестируй чужой код
Не нужно писать тесты на методы .NET (например,
List.Add()). Microsoft уже это сделала. Тестируй только свой код. -
Запускай тесты часто
Настрой автоматический запуск тестов при каждом сохранении файла. В Visual Studio есть функция «Live Unit Testing».
-
Следи за покрытием кода (Code Coverage)
Это метрика, показывающая, сколько процентов твоего кода покрыто тестами. Стремись к 70-80%, но не гонись за 100% — это часто неоправданно.
«Тесты — это не наказание, а инструмент свободы. Хорошие тесты позволяют тебе менять код, не боясь что-то сломать». — Роберт Мартин (Дядя Боб).
Вопросы для самопроверки
-
Вопрос: Что такое модульное тестирование?
Ответ: Это проверка самой маленькой части программы (метода, функции) в изоляции от остального кода.
-
Вопрос: Какой атрибут в xUnit используется для простого теста без параметров?
Ответ:
[Fact]. -
Вопрос: Чем отличается
[Fact]от[Theory]?Ответ:
[Fact]— тест без параметров, запускается один раз.[Theory]— тест с параметрами, запускается несколько раз с разными данными из[InlineData]. -
Вопрос: Какой метод используется для проверки, что метод выбрасывает исключение?
Ответ:
Assert.Throws<ТипИсключения>(() => ...). -
Вопрос: Что такое мок (Mock) и зачем он нужен?
Ответ: Мок — это объект-заглушка, который имитирует поведение реального объекта (например, базы данных). Нужен, чтобы тестировать код без подключения к реальным внешним ресурсам.
-
Вопрос: Какая команда используется для запуска тестов в консоли .NET?
Ответ:
dotnet test. -
Вопрос: Что означает аббревиатура AAA в тестировании?
Ответ: Arrange (Подготовка), Act (Действие), Assert (Проверка).
-
Вопрос: Почему не стоит тестировать приватные методы?
Ответ: Потому что тестировать нужно публичное поведение класса. Приватные методы — это детали реализации, которые могут меняться. Если они сложные, их нужно выносить в отдельные классы.
-
Вопрос: Что такое Code Coverage?
Ответ: Это метрика, показывающая, какой процент кода выполняется во время тестов. Высокий показатель (70-80%) — хорошо, но 100% не всегда нужно.
-
Вопрос: Какой фреймворк для моков чаще всего используется в C#?
Ответ: Moq (произносится «М