Перейти к содержанию
Educora
Продвинутый21 мин12 / 16

Тестирование с xUnit

Пиши модульные тесты на xUnit: `[Fact]`, `[Theory]` и `[InlineData]`, проверки, стиль FluentAssertions и подмена зависимостей с помощью Moq.

Проверь себя
В этом уроке ты узнаешь
  • Писать тестовый класс с [Fact] и методами Assert
  • Запускать один тест со многими данными через [Theory] и [InlineData]
  • Подменять интерфейс с помощью Moq и проверять вызовы

Если ты знакомился с JUnit в курсе Java, многое здесь покажется знакомым: и в C# тесты — это небольшие методы, которые вызывают код и сравнивают результат с ожидаемым. Самые популярные фреймворки тестирования в .NET — xUnit, NUnit и MSTest; шаблон dotnet new xunit и исходный код самого ASP.NET Core используют xUnit. Сначала посмотрим на идею теста без всяких библиотек:

C#
(int Score, string Expected)[] cases = [(95, "A"), (80, "B"), (72, "C"), (49, "F")];
int passed = 0;

foreach (var (score, expected) in cases)
{
    string actual = Letter(score);
    bool ok = actual == expected;
    if (ok) passed++;
    Console.WriteLine($"{(ok ? "PASS" : "FAIL")} Letter({score}) = {actual}, expected {expected}");
}
Console.WriteLine($"{passed} of {cases.Length} passed");

// a bug hides here: > 80 instead of >= 80
static string Letter(int score) => score switch
{
    >= 90 => "A",
    > 80 => "B",
    >= 70 => "C",
    >= 50 => "D",
    _ => "F"
};
Ожидаемый результат
PASS Letter(95) = A, expected A
FAIL Letter(80) = C, expected B
PASS Letter(72) = C, expected C
PASS Letter(49) = F, expected F
3 of 4 passed
Суть теста: одна проверка, много данных — ошибка на границе сразу выходит наружу

Тестовый проект и [Fact]

В прошлом уроке мы создали тестовый проект командой dotnet new xunit -n Gradebook.Tests. Шаблон .NET 10 добавляет пакеты xunit, xunit.runner.visualstudio и Microsoft.NET.Test.Sdk и делает пространство имён Xunit глобальным using. Тестовый класс — обычный public-класс без атрибутов. Тестовый метод без параметров помечают [Fact] — «факт, который всегда верен». Имена часто строят по схеме Method_Scenario_Expected, а сам тест имеет структуру AAA (Arrange, Act, Assert).

C#
using Gradebook.Core;

namespace Gradebook.Tests;

public class GradeCalculatorTests
{
    [Fact]
    public void Average_OfThreeScores_ReturnsMean()
    {
        int[] scores = [85, 90, 95];                          // Arrange
        double result = GradeCalculator.Average(scores);      // Act
        Assert.Equal(90.0, result, precision: 3);             // Assert
    }

    [Fact]
    public void Average_OfEmptyList_Throws()
    {
        var ex = Assert.Throws<ArgumentException>(() => GradeCalculator.Average([]));
        Assert.Equal("No scores", ex.Message);
    }

    [Theory]
    [InlineData(95, "A")]
    [InlineData(80, "B")]
    [InlineData(72, "C")]
    [InlineData(49, "F")]
    public void Letter_ReturnsExpectedGrade(int score, string expected)
    {
        Assert.Equal(expected, GradeCalculator.Letter(score));
    }
}
Gradebook.Tests/GradeCalculatorTests.cs — класс GradeCalculator из прошлого урока

Assert.Equal(expected, actual) — основная проверка; для дробных чисел precision задаёт число знаков после запятой. Assert.Throws<T> проверяет тип исключения и возвращает его. Другие полезные методы: Assert.True, Assert.Null, Assert.Contains, Assert.Empty, Assert.InRange. xUnit создаёт новый экземпляр класса для каждого теста: код подготовки пишут в конструкторе, а очистку — в IDisposable.Dispose(); отдельных атрибутов [SetUp] нет.

JUnit 5xUnitСмысл
@Test[Fact]один тест
@ParameterizedTest + @CsvSource[Theory] + [InlineData]тест с набором данных
@BeforeEach / @AfterEachконструктор / Dispose()перед / после каждого теста
@BeforeAllIClassFixture<T>общая подготовка для всего класса
assertThrowsAssert.Throws<T>проверка исключения

Тесты запускает dotnet test (в Visual Studio и Rider — окно Test Explorer). Допустим, как в примере выше, в методе Letter условие >= 80 случайно превратилось в > 80:

Terminal
dotnet test
Ожидаемый результат
Failed Gradebook.Tests.GradeCalculatorTests.Letter_ReturnsExpectedGrade(score: 80, expected: "B") [2 ms]
  Error Message:
   Assert.Equal() Failure: Strings differ
Expected: "B"
Actual:   "C"

Failed!  - Failed:     1, Passed:     5, Skipped:     0, Total:     6, Duration: 41 ms - Gradebook.Tests.dll (net10.0)
Пример вывода (сокращён): случай [InlineData(80, "B")] поймал ошибку

[Theory] и тестовые данные

[Theory] запускает один тест с разными данными: каждый атрибут [InlineData] — отдельный тестовый случай, и в отчёте он виден со своими параметрами; выше всего было шесть тестовых случаев. Если данные не помещаются в атрибут (например, объекты или списки), [MemberData(nameof(Cases))] берёт их из статического свойства. А при тестировании асинхронного кода метод обязательно должен возвращать async Task:

Неверно: `async void`
[Fact]
public async void LoadsScores()          // async void: the runner cannot await it
{
    var scores = await repository.LoadAsync("Aysel");
    Assert.NotEmpty(scores);
}
Верно: `async Task`
[Fact]
public async Task LoadsScores()          // async Task: xUnit awaits the result
{
    var scores = await repository.LoadAsync("Aysel");
    Assert.NotEmpty(scores);
}
Тестовый раннер не может дождаться метода async void, поэтому его ошибка может потеряться или всплыть в другом месте

Подмена зависимостей с Moq

ReportService получает баллы из интерфейса IScoreRepository; в настоящей программе за ним стоит база данных. Модульному тесту база не нужна: библиотека Moq (dotnet add package Moq) создаёт поддельный объект интерфейса — мок. Зависимость приходит в класс через конструктор; здесь он записан как первичный конструктор (primary constructor) из C# 12.

C#
public interface IScoreRepository
{
    IReadOnlyList<int> GetScores(string student);   // e.g. reads a database
}

public class ReportService(IScoreRepository repository)
{
    public string Summary(string student)
    {
        double average = GradeCalculator.Average(repository.GetScores(student));
        return $"{student}: {GradeCalculator.Letter((int)average)}";
    }
}
Сервис знает интерфейс, а не конкретную базу
C#
using Moq;

public class ReportServiceTests
{
    [Fact]
    public void Summary_UsesScoresFromRepository()
    {
        var repo = new Mock<IScoreRepository>();
        repo.Setup(r => r.GetScores("Aysel")).Returns(new List<int> { 90, 80 });

        var service = new ReportService(repo.Object);
        string summary = service.Summary("Aysel");

        Assert.Equal("Aysel: B", summary);
        repo.Verify(r => r.GetScores("Aysel"), Times.Once);
    }
}
Тест с Moq: поддельный репозиторий возвращает 90 и 80, среднее 85 — «B»

new Mock<IScoreRepository>() создаёт поддельный объект, Setup(...).Returns(...) учит его ответу, а repo.Object — готовый экземпляр, который передают сервису. Verify(..., Times.Once) проверяет, что метод вызван ровно один раз. Moq умеет подменять только интерфейсы и virtual-методы — ещё одна причина объявлять зависимости интерфейсами. Популярная альтернативная библиотека — NSubstitute.

Идея FluentAssertions — превратить проверки в цепочки, которые читаются как предложения: summary.Should().Be(...). Сообщения об ошибках тоже становятся подробнее. Внимание: начиная с версии 8 (2025) FluentAssertions требует платной лицензии для коммерческих проектов; бесплатная альтернатива — например, библиотека Shouldly.

Классический `Assert`
Assert.Equal("Aysel: B", summary);
Assert.Equal(3, scores.Count);
Assert.Contains(95, scores);
Стиль FluentAssertions
summary.Should().Be("Aysel: B");
scores.Should().HaveCount(3).And.Contain(95);

Что тестировать? Правило пирамиды тестов гласит: больше всего быстрых модульных тестов (один класс, поддельные зависимости), меньше интеграционных (с настоящей базой или веб-сервером) и меньше всего UI-тестов, проверяющих всю систему целиком. Какая доля кода покрыта тестами, измеряет пакет coverlet.collector из шаблона: dotnet test --collect:"XPlat Code Coverage" создаёт файл отчёта. Но 100% покрытия — не цель: граничные случаи и места, где легко ошибиться, важнее.

Главное

  • xUnit: [Fact] — тест без параметров, [Theory] + [InlineData] — тест с данными.
  • Assert.Equal(expected, actual), Assert.Throws<T>, Assert.Contains — основные проверки.
  • Каждый тест работает на новом объекте: подготовка — в конструкторе, очистка — в Dispose().
  • Асинхронные тесты возвращают async Task, никогда async void.
  • Moq: Setup(...).Returns(...) задаёт ответ, .Object даёт поддельный экземпляр, Verify проверяет вызов.

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Каким атрибутом помечают тест, параметры которого берутся из [InlineData]?