- Писать тестовый класс с
[Fact]и методамиAssert - Запускать один тест со многими данными через
[Theory]и[InlineData] - Подменять интерфейс с помощью Moq и проверять вызовы
Если ты знакомился с JUnit в курсе Java, многое здесь покажется знакомым: и в C# тесты — это небольшие методы, которые вызывают код и сравнивают результат с ожидаемым. Самые популярные фреймворки тестирования в .NET — xUnit, NUnit и MSTest; шаблон dotnet new xunit и исходный код самого ASP.NET Core используют xUnit. Сначала посмотрим на идею теста без всяких библиотек:
(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).
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 5 | xUnit | Смысл |
|---|---|---|
@Test | [Fact] | один тест |
@ParameterizedTest + @CsvSource | [Theory] + [InlineData] | тест с набором данных |
@BeforeEach / @AfterEach | конструктор / Dispose() | перед / после каждого теста |
@BeforeAll | IClassFixture<T> | общая подготовка для всего класса |
assertThrows | Assert.Throws<T> | проверка исключения |
Тесты запускает dotnet test (в Visual Studio и Rider — окно Test Explorer). Допустим, как в примере выше, в методе Letter условие >= 80 случайно превратилось в > 80:
dotnet testFailed 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:
[Fact]
public async void LoadsScores() // async void: the runner cannot await it
{
var scores = await repository.LoadAsync("Aysel");
Assert.NotEmpty(scores);
}[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.
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)}";
}
}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);
}
}new Mock<IScoreRepository>() создаёт поддельный объект, Setup(...).Returns(...) учит его ответу, а repo.Object — готовый экземпляр, который передают сервису. Verify(..., Times.Once) проверяет, что метод вызван ровно один раз. Moq умеет подменять только интерфейсы и virtual-методы — ещё одна причина объявлять зависимости интерфейсами. Популярная альтернативная библиотека — NSubstitute.
Идея FluentAssertions — превратить проверки в цепочки, которые читаются как предложения: summary.Should().Be(...). Сообщения об ошибках тоже становятся подробнее. Внимание: начиная с версии 8 (2025) FluentAssertions требует платной лицензии для коммерческих проектов; бесплатная альтернатива — например, библиотека Shouldly.
Assert.Equal("Aysel: B", summary);
Assert.Equal(3, scores.Count);
Assert.Contains(95, scores);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.
[InlineData]?