🔷 C#

Тестирование кода в C# (.NET): подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты печешь пирог по новому рецепту. Ты смешиваешь муку, яйца, сахар, ставишь в духовку. Но прежде чем подать этот пирог гостям, ты обязательно его попробуешь: не пересолен ли? Пропекся ли внутри? Не подгорел ли снизу? Этот процесс «пробы» и есть тестирование.

В программировании всё точно так же. Когда ты пишешь код, ты создаешь «рецепт» для компьютера. Но компьютер — очень буквальный «повар». Если в рецепте написано «добавить соли», а ты забыл указать количество, компьютер может добавить всю пачку. Или если ты написал «перемешать 10 раз», а нужно было «взбить миксером», пирог не получится.

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

«Код без тестов — это не программа, а просто набор символов, который, возможно, когда-то работал». — народная мудрость программистов.

Зачем это нужно школьнику или новичку? Представь, что ты написал игру «Угадай число». Ты запустил её, поиграл — всё работает. Но через неделю ты решил добавить подсчёт очков. Изменил код, запустил — игра работает, но теперь число угадывается не с 1 до 100, а с 1 до 50. Ты случайно изменил строчку. Если бы у тебя были тесты, они бы сразу сказали: «Ошибка! Игра должна загадывать числа до 100, а загадывает до 50!»

Тестирование экономит часы, дни и нервы. Профессиональные программисты тратят на тесты до 40% своего времени. И это не потому, что они не умеют писать код, а потому что они хотят спать спокойно, зная, что их программа не упадет в самый ответственный момент.

Теория: как это работает изнутри

В мире .NET (C#) тестирование делится на несколько уровней. Представь себе матрёшку:

  • Модульное тестирование (Unit Testing) — проверка самой маленькой части программы: одного метода, одной функции. Как проверка одного яйца на свежесть перед тем, как разбить его в тесто.
  • Интеграционное тестирование — проверка, как разные части программы работают вместе. Как проверка, что мука и яйца действительно смешиваются в тесто, а не рассыпаются по столу.
  • Функциональное тестирование (E2E) — проверка программы целиком, как её видит пользователь. Как дегустация готового пирога.

В этой статье мы сосредоточимся на модульном тестировании, потому что это основа основ. Его пишут сами разработчики, и оно выполняется автоматически за доли секунды.

Как это работает изнутри?

У тебя есть класс Calculator с методом Add(int a, int b), который возвращает сумму. Ты пишешь тест:

  1. Создаёшь экземпляр класса Calculator.
  2. Вызываешь метод Add(2, 3).
  3. Проверяешь: результат равен 5?

Если результат не 5 — тест падает (красный). Если 5 — тест проходит (зелёный). Всё просто.

В C# для этого существует специальный фреймворк — xUnit (или NUnit, MSTest). Это библиотека, которая даёт тебе инструменты: атрибуты для обозначения тестов, классы для проверки результатов (Assert).

Сам процесс выглядит так:

  1. Ты создаёшь отдельный проект в решении (Solution) — например, MyApp.Tests.
  2. Добавляешь ссылку на основной проект (MyApp).
  3. Пишешь классы с тестами.
  4. Запускаешь тесты через специальное окно в 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(() => calc.Divide(1, 0));
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#.

Типичные ошибки и подводные камни

  1. Тест ничего не проверяет

    Самая частая ошибка новичков — написать тест, который всегда проходит. Например:

    
    [Fact]
    public void Test()
    {
        var calc = new Calculator();
        calc.Add(1, 2); // Забыли Assert!
        // Тест пройдёт, даже если Add работает неправильно
    }
    

    Решение: Всегда добавляй Assert. Если тест не содержит проверки — он бесполезен.

  2. Тест зависит от других тестов

    Тесты должны быть независимыми. Если один тест меняет глобальную переменную, а второй от неё зависит — это бомба замедленного действия.

    Решение: Каждый тест должен сам создавать все нужные данные (Arrange) и не полагаться на порядок запуска.

  3. Тестирование приватных методов

    Новички часто пытаются тестировать приватные (скрытые) методы. Это неправильно. Тестировать нужно публичное поведение класса.

    Решение: Если приватный метод настолько сложен, что требует тестов, вынеси его в отдельный публичный класс.

  4. Слишком много тестов на один метод

    Не нужно писать 100 тестов на метод сложения. Достаточно проверить основные случаи: положительные числа, отрицательные, ноль, граничные значения.

    Решение: Используй [Theory] с [InlineData], чтобы сократить количество кода.

  5. Игнорирование тестов, которые падают

    Если тест упал, нельзя просто поставить [Fact(Skip = "Потом починю")]. Это путь к тому, что все тесты перестанут что-либо значить.

    Решение: Падающий тест — это красный флаг. Либо чини код, либо удаляй тест, если он больше не актуален.

  6. Тестирование кода, который работает с внешними ресурсами

    Не тестируй реальную базу данных, файловую систему или интернет в модульных тестах. Это медленно и ненадёжно.

    Решение: Используй моки (Moq) или создавай тестовые базы данных в памяти.

Лучшие практики и советы профессионалов

  1. Правило трёх A (AAA)

    Каждый тест должен состоять из трёх частей: Arrange (подготовка), Act (действие), Assert (проверка). Разделяй их пустыми строками для читаемости.

  2. Один тест — одна проверка

    Старайся проверять только одну вещь в одном тесте. Если тест упал, ты сразу поймёшь, что именно сломалось.

  3. Пиши тесты до кода (TDD — Test Driven Development)

    Сначала напиши тест, который падает, потом напиши код, который заставит тест пройти. Это звучит странно, но это дисциплинирует и заставляет думать о том, как будет использоваться твой код.

  4. Используй осмысленные имена

    Название теста должно описывать, что он проверяет. Плохо: Test1(). Хорошо: Deposit_NegativeAmount_ThrowsException().

  5. Не тестируй чужой код

    Не нужно писать тесты на методы .NET (например, List.Add()). Microsoft уже это сделала. Тестируй только свой код.

  6. Запускай тесты часто

    Настрой автоматический запуск тестов при каждом сохранении файла. В Visual Studio есть функция «Live Unit Testing».

  7. Следи за покрытием кода (Code Coverage)

    Это метрика, показывающая, сколько процентов твоего кода покрыто тестами. Стремись к 70-80%, но не гонись за 100% — это часто неоправданно.

«Тесты — это не наказание, а инструмент свободы. Хорошие тесты позволяют тебе менять код, не боясь что-то сломать». — Роберт Мартин (Дядя Боб).

Вопросы для самопроверки

  1. Вопрос: Что такое модульное тестирование?

    Ответ: Это проверка самой маленькой части программы (метода, функции) в изоляции от остального кода.

  2. Вопрос: Какой атрибут в xUnit используется для простого теста без параметров?

    Ответ: [Fact].

  3. Вопрос: Чем отличается [Fact] от [Theory]?

    Ответ: [Fact] — тест без параметров, запускается один раз. [Theory] — тест с параметрами, запускается несколько раз с разными данными из [InlineData].

  4. Вопрос: Какой метод используется для проверки, что метод выбрасывает исключение?

    Ответ: Assert.Throws<ТипИсключения>(() => ...).

  5. Вопрос: Что такое мок (Mock) и зачем он нужен?

    Ответ: Мок — это объект-заглушка, который имитирует поведение реального объекта (например, базы данных). Нужен, чтобы тестировать код без подключения к реальным внешним ресурсам.

  6. Вопрос: Какая команда используется для запуска тестов в консоли .NET?

    Ответ: dotnet test.

  7. Вопрос: Что означает аббревиатура AAA в тестировании?

    Ответ: Arrange (Подготовка), Act (Действие), Assert (Проверка).

  8. Вопрос: Почему не стоит тестировать приватные методы?

    Ответ: Потому что тестировать нужно публичное поведение класса. Приватные методы — это детали реализации, которые могут меняться. Если они сложные, их нужно выносить в отдельные классы.

  9. Вопрос: Что такое Code Coverage?

    Ответ: Это метрика, показывающая, какой процент кода выполняется во время тестов. Высокий показатель (70-80%) — хорошо, но 100% не всегда нужно.

  10. Вопрос: Какой фреймворк для моков чаще всего используется в C#?

    Ответ: Moq (произносится «М