Форум программистов, компьютерный форум, киберфорум
UnmanagedCoder
Войти
Регистрация
Восстановить пароль

NUnit и C#

Запись от UnmanagedCoder размещена 07.06.2025 в 10:46
Показов 6045 Комментарии 0
Метки .net, c#, mock, moq, nunit, unit-test

Нажмите на изображение для увеличения
Название: NUnit и C#.jpg
Просмотров: 131
Размер:	144.6 Кб
ID:	10885
В .NET существует несколько фреймворков для тестирования: MSTest (встроенный в Visual Studio), xUnit.net (более новый фреймворк) и, собственно, NUnit. Каждый имеет свои преимущества, но NUnit выделяется богатством возможностей и отличной документацией. Он поддерживает параллельное выполнение тестов, асинхронное тестирование, параметризацию и многое другое. Сравнивая NUnit с конкурентами, можно заметить несколько ключевых отличий. В отличие от MSTest, NUnit не привязан к Visual Studio и легко интегрируется с любой средой разработки. По сравнению с xUnit, NUnit предлагает более богатый набор атрибутов и более привычный API. MSTest долгое время отставал по функциональности, хотя в последние годы разрыв сократился.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Пример теста в MSTest
[TestClass]
public class CalculatorTests
{
    [TestMethod]
    public void AddTest()
    {
        Assert.AreEqual(4, 2 + 2);
    }
}
 
// Тот же тест в NUnit
[TestFixture]
public class CalculatorTests
{
    [Test]
    public void AddTest()
    {
        Assert.AreEqual(4, 2 + 2);
    }
}
 
// И в xUnit
public class CalculatorTests
{
    [Fact]
    public void AddTest()
    {
        Assert.Equal(4, 2 + 2);
    }
}
Установка NUnit проста и не требует особых навыков. Самый простой способ - через NuGet Package Manager. Достаточно открыть консоль диспетчера пакетов в Visual Studio и выполнить команду:

C#
1
2
Install-Package NUnit
Install-Package NUnit3TestAdapter
Первый пакет содержит сам фреймворк, а второй - адаптер, который позволяет запускать тесты прямо из Visual Studio. Для тех, кто предпочитает графический интерфейс, существует NUnit GUI (хотя в последнее время разработчики больше фокусируются на консольном запуске).

NUnit обладает стройной архитектурой, основанной на атрибутах. Ядро фреймворка обрабатывает аннотированные методы и классы, организуя их в тесты и тестовые наборы. Внутренний движок NUnit анализирует сборку, находит все тестовые методы и создает для них экземпляры тестовых классов. Один из ключевых аспектов работы NUnit - его механизм жизненного цикла тестов. Когда вы запускаете тест, фреймворк:
1. Создает экземпляр тестового класса,
2. Вызывает методы с атрибутом [SetUp] (если есть),
3. Запускает сам тестовый метод,
4. Вызывает методы с атрибутом [TearDown] (если есть),
5. Уничтожает экземпляр класса.
Этот цикл повторяется для каждого теста, что обеспечивает их изоляцию друг от друга. Впрочем, иногда это поведение можно настроить, используя атрибуты уровня класса.

В основе философии NUnit лежит принцип "трех А": Arrange (подготовка), Act (действие) и Assert (проверка). Это классическая структура хорошего модульного теста. Вначале вы настраиваете тестовое окружение, затем выполняете тестируемое действие и, наконец, проверяете результат.

C#
1
2
3
4
5
6
7
8
9
10
11
12
[Test]
public void DepositShouldIncreaseBalance()
{
    // Arrange - подготовка
    var account = new BankAccount(100);
    
    // Act - действие
    account.Deposit(50);
    
    // Assert - проверка
    Assert.AreEqual(150, account.Balance);
}
Я заметил, что многие начинающие разработчики путаются в этих понятиях, смешивая подготовку с действием или добавляя лишние проверки. Практика показывает, что четкое разделение этих фаз делает тесты более понятными и надежными.

Интеграция NUnit с Visual Studio достаточно прозрачна. После установки адаптера тесты появляются в обозревателе тестов (Test Explorer). Вы можете запускать их по одному, группами или все сразу. Visual Studio также показывает результаты тестов и позволяет переходить к коду теста непосредственно из обозревателя. Для тех, кто предпочитает командную строку или использует CI/CD пайплайны, существует консольный запуск NUnit. Достаточно установить пакет NUnit.ConsoleRunner и использовать команду nunit3-console для запуска тестов.

Одной из сильных сторон NUnit является возможность тонкой настройки тестового окружения. Фреймворк позволяет конфигурировать практически любой аспект выполнения тестов - от порядка их запуска до способа формирования отчетов. Для этого используется файл конфигурации NUnit.config, который можно разместить в корне проекта или указать его расположение при запуске тестов.

XML
1
2
3
4
5
6
7
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <settings>
    <setting name="NumberOfTestWorkers" value="4" />
    <setting name="StopOnError" value="false" />
  </settings>
</configuration>
Если вникнуть в архитектуру NUnit глубже, можно заметить, что фреймворк использует систему плагинов для расширения своих возможностей. Благодаря этому, разработчики могут создавать собственные расширения, добавлять новые функции или интегрировать NUnit с другими инструментами. Например, существуют плагины для генерации отчетов в формате HTML, XML или интеграции с инструментами для измерения покрытия кода.

Исторически NUnit развивался вместе с платформой .NET, адаптируясь к ее изменениям. Когда в C# появились обобщенные типы (дженерики), NUnit быстро добавил поддержку параметризованных тестов. Когда появились асинхронные методы с async/await, фреймворк внедрил соответствующие возможности для тестирования такого кода. Внутренние механизмы NUnit включают довольно сложную систему обнаружения и запуска тестов. Процесс начинается с поиска сборок, содержащих тесты. Затем фреймворк анализирует каждую сборку, находя все классы с атрибутом [TestFixture] и методы с атрибутом [Test]. После этого NUnit создает внутреннее дерево тестов, где узлами являются тестовые классы и методы, а также определяет порядок выполнения и зависимости между тестами.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class TestDiscoverer
{
    public IEnumerable<TestCase> DiscoverTests(Assembly assembly)
    {
        var testFixtures = assembly.GetTypes()
            .Where(t => t.GetCustomAttributes(typeof(TestFixtureAttribute), true).Any());
            
        foreach (var fixture in testFixtures)
        {
            var testMethods = fixture.GetMethods()
                .Where(m => m.GetCustomAttributes(typeof(TestAttribute), true).Any());
                
            foreach (var method in testMethods)
            {
                yield return new TestCase(fixture, method);
            }
        }
    }
}
Приведенный выше пример - это упрощенная версия того, как NUnit обнаруживает тесты. Реальный код намного сложнее и учитывает множество нюансов, таких как наследование, вложенные классы, параметризацию и другие особености.

В моей практике часто возникает необходимость запускать тесты на разных платформах или с разными конфигурациями. NUnit предлагает для этого несколько механизмов. Один из них - атрибут [Platform], который позволяет указать, на какой операционной системе должен выполняться тест:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
[Test]
[Platform("Win")]
public void TestWindowsOnly()
{
    // Этот тест будет запущен только на Windows
}
 
[Test]
[Platform("Linux")]
public void TestLinuxOnly()
{
    // Этот тест будет запущен только на Linux
}
Другой механизм - атрибут [Culture], который позволяет запускать тесты только для определенной культуры:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Test]
[Culture("en-US")]
public void TestUSFormat()
{
    // Проверка форматирования для американской культуры
    Assert.AreEqual("1,234.56", 1234.56.ToString("N2"));
}
 
[Test]
[Culture("ru-RU")]
public void TestRussianFormat()
{
    // Проверка форматирования для русской культуры
    Assert.AreEqual("1 234,56", 1234.56.ToString("N2"));
}
Еще одна интересная особенность NUnit - встроенная поддержка случайных данных. Фреймворк предоставляет класс RandomGenerator, который можно использовать для генерации случайных чисел, строк и других типов данных. Это особенно полезно для property-based тестирования:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Test]
public void StringReverse_ShouldBeSymmetric()
{
    // Создаем генератор случайных строк
    var random = TestContext.CurrentContext.Random;
    
    // Генерируем случайную строку длиной до 100 символов
    string original = random.GetString(100);
    
    // Переворачиваем дважды - должны получить исходную строку
    string reversed = new string(original.Reverse().ToArray());
    string doubleReversed = new string(reversed.Reverse().ToArray());
    
    Assert.AreEqual(original, doubleReversed);
}
В одном из моих проектов мы столкнулись с проблемой: тесты случайно падали из-за разного порядка выполнения. Оказалось, что один тест изменял глобальное состояние, которое использовал другой тест. NUnit помог обнаружить проблему благодаря возможности фиксировать порядок выполнения тестов с помощью атрибута [Order]:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
[Test]
[Order(1)]
public void FirstTest()
{
    // Этот тест будет выполнен первым
}
 
[Test]
[Order(2)]
public void SecondTest()
{
    // Этот тест будет выполнен вторым
}
Я считаю, что основная сила NUnit - в его гибкости и богатстве функций. Фреймворк постоянно развивается, добавляя новые возможности и улучшая существующие. При этом он остается обратно совместимым, что важно для долгосрочных проектов.
Одна из относительно новых функций, которую я активно использую - параллельное выполнение тестов. NUnit позволяет запускать тесты одновременно, что значительно сокращает время выполнения всего набора. Это особено важно для CI/CD пайплайнов, где каждая минута на счету.

Структура тестов



Атрибуты - краеугольный камень всей экосистемы NUnit. Когда я только начинал знакомиться с фреймворком, именно система атрибутов поразила меня своей лаконичностью и выразительностью. Базовый набор атрибутов NUnit включает [TestFixture], [Test], [SetUp], [TearDown] и многие другие. Каждый из них играет свою роль в организации тестовой структуры. Атрибут [TestFixture] маркирует класс как контейнер для тестов. Обычно такой класс содержит несколько связанных тестовых методов, проверяющих один компонент системы. Важный нюанс: по умолчанию NUnit создает новый экземпляр класса для каждого тестового метода, что обеспечивает изоляцию тестов.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[TestFixture]
public class CalculatorTests
{
    private Calculator _calculator;
    
    [SetUp]
    public void Initialize()
    {
        _calculator = new Calculator();
    }
    
    [Test]
    public void Add_ShouldReturnCorrectSum()
    {
        var result = _calculator.Add(5, 3);
        Assert.AreEqual(8, result);
    }
    
    [TearDown]
    public void Cleanup()
    {
        _calculator.Dispose();
    }
}
Атрибут [Test] указывает, что метод является тестом и должен быть вызван при запуске тестового набора. Метод с этим атрибутом обязан быть публичным, не возвращать значения и не принимать параметров (если только это не параметризованый тест). Методы с атрибутами [SetUp] и [TearDown] вызываются соответственно до и после каждого тестового метода. Это идеальное место для инициализации обьектов и освобождения ресурсов. Особено полезно, когда у вас много тестов, использующих одинаковую подготовку.

Для единоразовой настройки и очистки на уровне всего тестового класса существуют атрибуты [OneTimeSetUp] и [OneTimeTearDown]. Методы с этими атрибутами выполняются один раз до начала и после завершения всех тестов в классе соответственно.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
[TestFixture]
public class DatabaseTests
{
    private static DbConnection _connection;
    
    [OneTimeSetUp]
    public static void InitializeDatabase()
    {
        // Создаем подключение к БД один раз для всех тестов
        _connection = new DbConnection("connection_string");
        _connection.Open();
    }
    
    [Test]
    public void GetUser_ShouldReturnCorrectUser()
    {
        var repo = new UserRepository(_connection);
        var user = repo.GetById(1);
        Assert.IsNotNull(user);
        Assert.AreEqual("admin", user.Username);
    }
    
    [OneTimeTearDown]
    public static void CloseDatabase()
    {
        // Закрываем подключение после всех тестов
        _connection.Close();
        _connection.Dispose();
    }
}
В моей практике часто встречаются ситуации, когда необходимо игнорировать некоторые тесты. Например, тест нерелевантен для определенной платформы или временно отключен из-за изменений в API. Для этого NUnit предоставляет атрибут [Ignore], который можно применить к методу или целому классу:

C#
1
2
3
4
5
6
[Test]
[Ignore("API для этой функции изменится в следуюшем спринте")]
public void SomeFeatureTest()
{
    // Этот тест не будет выполнен
}
Сердце любого тестового фреймворка - класс Assert. NUnit предлагает исчерпывающий набор методов для проверки различных условий. Основные из них: Assert.AreEqual, Assert.IsTrue, Assert.IsFalse, Assert.IsNull, Assert.IsNotNull. Кроме того, есть специализированные проверки для коллекций, исключений, сравнений и многого другого.
Методы Assert в NUnit реализуют так называемый "флюент интерфейс", что делает код тестов более читабельным. Например, работая с колекциями, можно использовать такие выражения:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Test]
public void Collection_ShouldContainExpectedElements()
{
    var numbers = new List<int> { 1, 2, 3, 4, 5 };
    
    // Проверка коллекции на несколько условий
    CollectionAssert.AllItemsAreNotNull(numbers);
    CollectionAssert.AllItemsAreUnique(numbers);
    CollectionAssert.Contains(numbers, 3);
    
    // Или с помощью ограничений (constraints)
    Assert.That(numbers, Has.Count.EqualTo(5));
    Assert.That(numbers, Has.Member(3));
    Assert.That(numbers, Is.Ordered);
}
Одна из мощных фич NUnit - система ограничений (constraints). Она предоставляет единный синтаксис для описания различных условий через метод Assert.That. Синтаксис получается очень близким к обычному английскому языку, что делает тесты более понятными:

C#
1
2
3
4
5
6
7
8
9
10
[Test]
public void String_ShouldMatchExpectedPattern()
{
    string email = "user@example.com";
    
    // Проверка с использованием ограничений
    Assert.That(email, Is.Not.Null);
    Assert.That(email, Does.Contain("@"));
    Assert.That(email, Does.Match(@".+@.+\..+"));
}
В сложных тестах бывает необходимо проверить выброс исключения при определенных условиях. NUnit предлагает для этого атрибут [ExpectedException], но современный подход рекомендует использовать конструкцию Assert.Throws:

C#
1
2
3
4
5
6
7
8
9
10
11
12
[Test]
public void DivideByZero_ShouldThrowException()
{
    var calculator = new Calculator();
    
    // Проверка на выброс исключения
    Assert.Throws<DivideByZeroException>(() => calculator.Divide(10, 0));
    
    // Более сложная проверка с доступом к объекту исключения
    var ex = Assert.Throws<ArgumentException>(() => calculator.SquareRoot(-1));
    StringAssert.Contains("Отрицательное число", ex.Message);
}
Организация тестовых данных - важнейший аспект тестирования. Чем сложнее система, тем больше вариантов входных данных нужно проверить. NUnit предлагает несколько механизмов для работы с тестовыми данными, включая атрибуты [TestCase], [TestCaseSource], [ValueSource] и другие.
Атрибут [TestCase] позволяет запустить один и тот же тестовый метод с разными наборами параметров:

C#
1
2
3
4
5
6
7
8
9
10
11
[Test]
[TestCase(5, 3, 8)]
[TestCase(0, 0, 0)]
[TestCase(-5, 5, 0)]
[TestCase(int.MaxValue, 1, int.MinValue)] // Переполнение
public void Add_ShouldReturnCorrectSum(int a, int b, int expectedSum)
{
    var calculator = new Calculator();
    var result = calculator.Add(a, b);
    Assert.AreEqual(expectedSum, result);
}
Для более сложных сценариев, когда тестовые данные генерируются динамически или извлекаются из внешних источников, существует атрибут [TestCaseSource]:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static IEnumerable<TestCaseData> AddTestCases
{
    get
    {
        yield return new TestCaseData(5, 3).Returns(8).SetName("Positive numbers");
        yield return new TestCaseData(0, 0).Returns(0).SetName("Zeros");
        yield return new TestCaseData(-5, 5).Returns(0).SetName("Mixed signs");
    }
}
 
[Test]
[TestCaseSource(nameof(AddTestCases))]
public int Add_ShouldReturnCorrectSum(int a, int b)
{
    var calculator = new Calculator();
    return calculator.Add(a, b);
}
Категоризация тестов - еще одна полезная возможность NUnit. С помощью атрибута [Category] можно группировать тесты по любому признаку, а затем запускать только определенные категории:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
[Test]
[Category("Integration")]
public void DatabaseTest()
{
    // Тест, требующий доступа к базе данных
}
 
[Test]
[Category("Unit")]
public void CalculationTest()
{
    // Изолированный модульный тест
}
При запуске тестов через консоль или в CI-системе можно указать, какие категории включить или исключить:

Bash
1
nunit3-console tests.dll --where "cat == Unit"
На моем текущем проекте мы активно используем категоризацию для разделения быстрых модульных тестов и более медленных интеграционных. Это позволяет разработчикам запускать только легковесные тесты во время локальной разработки, а полный набор выполняется на CI-сервере.

Наследование тестовых классов - мощный инструмент для организации тестов. Базовый класс может содержать общую логику инициализации и очистки, а наследники - конкретные тестовые методы. Это особено полезно при тестировании различных реализаций одного интерфейса:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[TestFixture]
public abstract class StorageTests
{
    protected IStorage Storage;
    
    [SetUp]
    public virtual void Initialize()
    {
        // Общая инициализация
    }
    
    [Test]
    public void Get_ShouldReturnStoredValue()
    {
        Storage.Set("key", "value");
        Assert.AreEqual("value", Storage.Get("key"));
    }
}
 
[TestFixture]
public class FileStorageTests : StorageTests
{
    [SetUp]
    public override void Initialize()
    {
        base.Initialize();
        Storage = new FileStorage("test.dat");
    }
}
 
[TestFixture]
public class MemoryStorageTests : StorageTests
{
    [SetUp]
    public override void Initialize()
    {
        base.Initialize();
        Storage = new MemoryStorage();
    }
}
Абстрактные тестовые сценарии я также активно использую для тестирования сложных алгоритмов с разными вариантами входных данных. Определяя абстрактный метод для генерации данных, можно создать множество различных реализаций и запустить одинаковые проверки для всех них:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
[TestFixture]
public abstract class SortingAlgorithmTests
{
    protected abstract IList<int> Sort(IList<int> input);
    protected abstract string AlgorithmName { get; }
    
    [Test]
    public void Sort_EmptyArray_ReturnsEmptyArray()
    {
        var result = Sort(new List<int>());
        Assert.That(result, Is.Empty);
    }
    
    [Test]
    public void Sort_SingleElement_ReturnsSameElement()
    {
        var result = Sort(new List<int> { 42 });
        Assert.That(result, Has.Count.EqualTo(1));
        Assert.That(result[0], Is.EqualTo(42));
    }
    
    [Test]
    [TestCase(new[] { 3, 1, 4, 1, 5 }, new[] { 1, 1, 3, 4, 5 })]
    [TestCase(new[] { -5, 0, 5 }, new[] { -5, 0, 5 })]
    [TestCase(new[] { 9, 8, 7, 6 }, new[] { 6, 7, 8, 9 })]
    public void Sort_MultipleElements_ReturnsOrderedArray(int[] input, int[] expected)
    {
        var result = Sort(input.ToList());
        Assert.That(result, Is.EqualTo(expected));
    }
}
 
[TestFixture]
public class QuickSortTests : SortingAlgorithmTests
{
    protected override IList<int> Sort(IList<int> input)
    {
        return new QuickSort().Sort(input);
    }
    
    protected override string AlgorithmName => "QuickSort";
}
 
[TestFixture]
public class BubbleSortTests : SortingAlgorithmTests
{
    protected override IList<int> Sort(IList<int> input)
    {
        return new BubbleSort().Sort(input);
    }
    
    protected override string AlgorithmName => "BubbleSort";
}
В одном из наших проектов мы столкнулись с проблемой: некоторые тесты случайно падали при параллельном запуске. Оказалось, что они использовали общий ресурс - текущее системное время. NUnit предлагает элегантное решение: фикстуры с изоляцией по потокам:

C#
1
2
3
4
5
6
7
8
9
10
[TestFixture, Apartment(ApartmentState.STA)]
public class UIThreadTests
{
    [Test]
    public void SomeUIOperation_ShouldNotCrash()
    {
        // Этот тест выполняется в отдельном STA-потоке,
        // что необходимо для работы с UI-компонентами
    }
}
Работа с временными зависимостями - одна из самых сложных задач в тестировании. Как проверить код, который зависит от текущего времени или таймеров? NUnit предлагает несколько подходов. Самый простой - использование атрибута [Timeout], который ограничивает время выполнения теста:

C#
1
2
3
4
5
6
7
[Test]
[Timeout(1000)] // Тест не должен выполняться дольше 1 секунды
public void LongOperation_ShouldCompleteQuickly()
{
    var calculator = new Calculator();
    calculator.CalculateFactorial(10);
}
Для более сложных сценариев можно создать абстракцию времени в тестируемом коде. Вместо прямого использования DateTime.Now или Task.Delay, код может принимать интерфейс с методами для получения текущего времени:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public interface ITimeProvider
{
    DateTime Now { get; }
    Task Delay(TimeSpan delay, CancellationToken token = default);
}
 
public class SystemTimeProvider : ITimeProvider
{
    public DateTime Now => DateTime.Now;
    public Task Delay(TimeSpan delay, CancellationToken token = default) => Task.Delay(delay, token);
}
 
public class FakeTimeProvider : ITimeProvider
{
    private DateTime _currentTime = DateTime.Now;
    
    public DateTime Now => _currentTime;
    
    public void AdvanceTime(TimeSpan timeSpan)
    {
        _currentTime += timeSpan;
    }
    
    public Task Delay(TimeSpan delay, CancellationToken token = default)
    {
        // Возвращаем уже завершенную задачу вместо реального ожидания
        return Task.CompletedTask;
    }
}
В тестах можно использовать FakeTimeProvider для контроля времени:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Test]
public async Task ExpirationChecker_ShouldIdentifyExpiredItems()
{
    // Arrange
    var fakeTime = new FakeTimeProvider();
    var checker = new ExpirationChecker(fakeTime);
    var item = new ExpiringItem { ExpiresAt = fakeTime.Now.AddHours(1) };
    
    // Act & Assert
    Assert.IsFalse(checker.IsExpired(item));
    
    // Перематываем время на 2 часа вперед
    fakeTime.AdvanceTime(TimeSpan.FromHours(2));
    
    Assert.IsTrue(checker.IsExpired(item));
}
Я обнаружил, что в сложных системах тесты с зависимостью от времени часто бывают источником нестабильных результатов. В одном случае нам пришлось переписать целый модуль, чтобы сделать его детерминированным и независимым от системных часов. Зато потом тесты стали выполняться в несколько раз быстрее и больше никогда не падали случайным образом.

Работа с датой и временем в тестах имеет свои особености. Например, при проверке операций, зависящих от часового пояса, важно учитывать, что тесты могут запускаться на машинах с разными региональными настройками. В таких случаях лучше явно указывать используемый часовой пояс:

C#
1
2
3
4
5
6
7
8
9
10
11
[Test]
public void FormatTime_ShouldRespectTimeZone()
{
    // Создаем форматтер с явным указанием часового пояса
    var formatter = new TimeFormatter(TimeZoneInfo.FindSystemTimeZoneById("UTC"));
    
    // Создаем фиксированное время для теста
    var fixedTime = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc);
    
    Assert.AreEqual("12:00 PM", formatter.Format(fixedTime));
}
Ещё одно интересное применение абстрактных тестовых класов - проверка соблюдения контрактов интерфейсов. Я часто создаю базовый класс с тестами, проверяющими общее поведение всех реализаций интерфейса, а затем наследую от него конкретные тестовые классы:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
[TestFixture]
public abstract class CacheTests
{
    protected abstract ICache CreateCache();
    
    [Test]
    public void Get_NonExistentKey_ReturnsNull()
    {
        var cache = CreateCache();
        Assert.IsNull(cache.Get("nonexistent"));
    }
    
    [Test]
    public void Set_NewKey_StoresValue()
    {
        var cache = CreateCache();
        cache.Set("key", "value");
        Assert.AreEqual("value", cache.Get("key"));
    }
}
 
[TestFixture]
public class MemoryCacheTests : CacheTests
{
    protected override ICache CreateCache()
    {
        return new MemoryCache();
    }
    
    // Дополнительные тесты, специфичные для MemoryCache
}
 
[TestFixture]
public class RedisCacheTests : CacheTests
{
    protected override ICache CreateCache()
    {
        return new RedisCache("localhost:6379");
    }
    
    // Дополнительные тесты, специфичные для RedisCache
}
Такой подход гарантирует, что все реализации соответствуют общему контракту, и при этом позволяет тестировать специфичную функциональность каждой из них.

Блокирующая очередь. Как создать тест на Nunit для проверки равенства
есть блокирующая очередь.Как создать тест на Nunit для проверки равенства using System; using...

Модульное тестирование: nUnit в определенных ситуациях не хочет подгружать сборку
Начал разбираться с модульным тестированием и столкнулся с неприятными и непонятными моментами -...

Как тестировать многопоточные приложения в NUnit
Здравствуйте. Как тестировать многопоточные приложения в NUnit ? В общем нужно проверить...

Внедрение NUnit-тестов в проект
Добрый день, форумчане. Написал программу, теперь интересует как внедрить в нее NUnit тесты....


Продвинутые возможности



Параметризованные тесты - настоящий прорыв в методологии тестирования. Помню свой первый крупный проект, где мне пришлось писать десятки практически идентичных тестов, отличающихся только входными данными. Это был настоящий ад дублирования кода, пока я не открыл для себя возможности NUnit в этой области.

Атрибут [TestCase], о котором я уже упоминал ранее, это лишь верхушка айсберга. NUnit предлагает целую экосистему для работы с параметризоваными тестами. Одна из самых мощных возможностей - комбинаторное тестирование с помощью атрибута [Combinatorial]. Он позволяет автоматически создавать все возможные комбинации входных параметров:

C#
1
2
3
4
5
6
7
8
9
10
[Test]
[Combinatorial]
public void TestOperation(
    [Values(1, 2, 3)] int x,
    [Values("a", "b")] string y,
    [Values(true, false)] bool flag)
{
    // Этот тест запустится 12 раз с разными комбинациями параметров
    // (3 значения x * 2 значения y * 2 значения flag)
}
Для ситуаций, когда количество комбинаций слишком велико, можно использовать атрибут [Pairwise]. Он основан на методологии тестирования парных комбинаций, предполагая, что большинство ошибок проявляются при взаимодействии пар параметров, а не их тройных или более сложных комбинаций:

C#
1
2
3
4
5
6
7
8
9
10
11
[Test]
[Pairwise]
public void TestWithManyParameters(
    [Values(1, 2, 3, 4)] int a,
    [Values(1, 2, 3, 4)] int b,
    [Values(1, 2, 3, 4)] int c,
    [Values(1, 2, 3, 4)] int d)
{
    // Вместо 4^4 = 256 тестов, запустится значительно меньше,
    // но при этом все пары значений будут протестированы
}
Асинхронное тестирование - еще одна область, где NUnit показывает свою силу. В современном C# разработке асинхронный код скорее правило, чем исключение. Тестирование такого кода может быть нетривиальной задачей, но NUnit делает его максимально простым:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
[Test]
public async Task AsyncOperation_ShouldReturnCorrectResult()
{
    // Arrange
    var service = new DataService();
    
    // Act
    var result = await service.GetDataAsync();
    
    // Assert
    Assert.IsNotNull(result);
    Assert.That(result.Items, Has.Count.GreaterThan(0));
}
Я часто сталкиваюсь с необходимостью тестировать таймауты и отмену асинхронных операций. NUnit предоставляет удобные механизмы и для этого:

C#
1
2
3
4
5
6
7
8
9
10
11
[Test]
public void AsyncOperation_ShouldRespectCancellation()
{
    // Arrange
    var service = new DataService();
    using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
    
    // Act & Assert
    Assert.ThrowsAsync<OperationCanceledException>(async () => 
        await service.LongRunningOperationAsync(cts.Token));
}
Интересная особенность NUnit - поддержка разных моделей асинхронности. Фреймворк работает не только с задачами на основе TPL (Task Parallel Library), но и с устаревшими асинхронными паттернами, такими как APM (Asynchronous Programming Model) и EAP (Event-based Asynchronous Pattern). Это особено важно при тестировании унаследованного кода.

Моки и заглушки - неотъемлемая часть модульного тестирования. Хотя NUnit сам по себе не предоставляет инструментов для создания моков, он отлично работает с популярными библиотеками, такими как Moq, NSubstitute и FakeItEasy. Вот пример использования Moq с NUnit:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Test]
public void UserService_GetUser_ShouldReturnUserFromRepository()
{
    // Arrange
    var mockRepo = new Mock<IUserRepository>();
    mockRepo.Setup(repo => repo.GetById(1))
            .Returns(new User { Id = 1, Name = "Admin" });
            
    var service = new UserService(mockRepo.Object);
    
    // Act
    var user = service.GetUser(1);
    
    // Assert
    Assert.IsNotNull(user);
    Assert.AreEqual("Admin", user.Name);
    mockRepo.Verify(repo => repo.GetById(1), Times.Once);
}
В одном из моих проектов мы столкнулись с проблемой: некоторые тесты, использующие моки, были очень хрупкими - они ломались при малейшем изменении кода. Мы решили эту проблему, создав специальную абстракцию для настройки моков, что позволило сделать тесты более устойчивыми к изменениям:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class UserRepositoryMockBuilder
{
    private readonly Mock<IUserRepository> _mock = new Mock<IUserRepository>();
    
    public UserRepositoryMockBuilder WithUser(int id, string name)
    {
        _mock.Setup(repo => repo.GetById(id))
             .Returns(new User { Id = id, Name = name });
        return this;
    }
    
    public UserRepositoryMockBuilder WithException(int id)
    {
        _mock.Setup(repo => repo.GetById(id))
             .Throws<NotFoundException>();
        return this;
    }
    
    public IUserRepository Build()
    {
        return _mock.Object;
    }
}
 
[Test]
public void UserService_GetUser_ShouldThrowWhenUserNotFound()
{
    // Arrange
    var repository = new UserRepositoryMockBuilder()
        .WithException(1)
        .Build();
        
    var service = new UserService(repository);
    
    // Act & Assert
    Assert.Throws<UserNotFoundException>(() => service.GetUser(1));
}
Создание собственных атрибутов - продвинутая, но иногда необходимая возможность. NUnit позволяет расширять свою функциональность, создавая кастомные атрибуты для специфичных потребностей тестирования. Например, можно создать атрибут, который автоматически настраивает базу данных для тестов:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
[AttributeUsage(AttributeTargets.Method)]
public class DatabaseSetupAttribute : NUnit.Framework.Attributes.PropertyAttribute
{
    public string ConnectionString { get; }
    public string ScriptPath { get; }
    
    public DatabaseSetupAttribute(string connectionString, string scriptPath)
    {
        ConnectionString = connectionString;
        ScriptPath = scriptPath;
    }
}
 
public class DatabaseSetupActionAttribute : TestActionAttribute
{
    public override void BeforeTest(ITest test)
    {
        var attribute = test.Method.GetCustomAttributes<DatabaseSetupAttribute>(true).FirstOrDefault();
        if (attribute != null)
        {
            // Выполняем скрипт перед тестом
            using var connection = new SqlConnection(attribute.ConnectionString);
            connection.Open();
            var script = File.ReadAllText(attribute.ScriptPath);
            using var command = new SqlCommand(script, connection);
            command.ExecuteNonQuery();
        }
    }
    
    public override void AfterTest(ITest test)
    {
        // Очистка после теста
    }
}
 
[Test]
[DatabaseSetup("Data Source=test.db", "Scripts/SetupUser.sql")]
public void Database_ShouldContainUser()
{
    using var connection = new SqlConnection("Data Source=test.db");
    connection.Open();
    using var command = new SqlCommand("SELECT COUNT(*) FROM Users WHERE Username = 'admin'", connection);
    var count = (int)command.ExecuteScalar();
    Assert.AreEqual(1, count);
}
Разумеется, это упрощенный пример. В реальных проектах такие атрибуты могут быть намного сложнее и покрывать множество специфичных случаев.

Параллельное выполнение тестов - важный аспект для больших тестовых наборов. По умолчанию NUnit запускает тесты последовательно, но это поведение можно изменить, используя атрибуты [Parallelizable] и настройки в конфигурационном файле:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[TestFixture]
[Parallelizable(ParallelScope.Children)]
public class ParallelTests
{
    [Test]
    public async Task Test1()
    {
        await Task.Delay(1000);
        Assert.Pass();
    }
    
    [Test]
    public async Task Test2()
    {
        await Task.Delay(1000);
        Assert.Pass();
    }
    
    [Test]
    [Parallelizable(ParallelScope.None)] // Этот тест не будет выполняться параллельно
    public void NonParallelTest()
    {
        Thread.Sleep(1000);
        Assert.Pass();
    }
}
При параллельном выполнении важно учитывать доступ к общим ресурсам. Тесты, которые используют одну и ту же базу данных, файл или другой разделяемый ресурс, могут взаимодействовать непредсказуемым образом. Для решения этой проблемы NUnit предлагает атрибут [LevelOfParallelism], который ограничивает количество одновременно работающих тестов, и метаданные для группировки тестов, которые не должны выполняться одновременно.

Интеграция с внешними инструментами - еще одна сильная сторона NUnit. Фреймворк поддерживает различные форматы отчетов, которые могут быть использованы другими инструментами для анализа и визуализации результатов тестирования. Например, для интеграции с инструментами измерения покрытия кода, такими как OpenCover или Coverlet, достаточно запустить NUnit с соответствующими параметрами:

Bash
1
2
# Запуск тестов с измерением покрытия кода
dotnet test --collect:"XPlat Code Coverage"
В моей практике регулярно встречаются ситуации, когда необходимо тестировать сложные случаи с исключениями. NUnit предлагает несколько интересных подходов помимо стандартного Assert.Throws. Допустим, нам нужно проверить не только сам факт исключения, но и его внутренние свойства или вложенные исключения:

C#
1
2
3
4
5
6
7
8
9
10
[Test]
public void ValidateData_WithNestedErrors_ShouldThrowWithCorrectInnerException()
{
    var validator = new DataValidator();
    
    // Проверка вложенного исключения
    var ex = Assert.Throws<ValidationException>(() => validator.ValidateComplex(null));
    Assert.IsInstanceOf<ArgumentNullException>(ex.InnerException);
    Assert.That(ex.InnerException.Message, Does.Contain("Value cannot be null"));
}
Теория тестирования (Theory) - концепция, котрая всё более популярна в тестировании. В отличие от традиционного теста, теория описывает утверждение, которое должно быть истинно для широкого диапазона данных. В NUnit для этого используется атрибут [Theory] в сочетании с источниками данных:

C#
1
2
3
4
5
6
7
[Theory]
public void SquareRoot_ShouldWorkForAnyPositiveNumber(
    [Range(1, 100, 5)] double input)
{
    var result = Math.Sqrt(input);
    Assert.That(result * result, Is.EqualTo(input).Within(0.00001));
}
При тестировании недетерминированных операций, например, взаимодействия с внешними API, тесты могут иногда падать из-за временных проблем. Для таких ситуаций NUnit предлагает механизм повторных запусков с помощью атрибута [Retry]:

C#
1
2
3
4
5
6
7
8
[Test]
[Retry(3)] // Попытаться запустить до 3 раз в случае неудачи
public async Task ExternalApi_ShouldReturnValidResponse()
{
    var client = new HttpClient();
    var response = await client.GetAsync("https://api.example.com/data");
    Assert.That(response.IsSuccessStatusCode);
}
Я активно использую этот подход для тестов, которые обращаются к внешним сервисам. В одном проекте у нас было около 300 интеграционных тестов, и механизм повторных запусков значительно повысил их стабильность.
Ещё одна мощная фича NUnit - тесты с временными ограничениями. Иногда нужно гарантировать, что операция выполняется достаточно быстро:

C#
1
2
3
4
5
6
7
[Test]
[MaxTime(500)] // Максимальное время выполнения - 500 мс
public void Operation_ShouldBePerformedQuickly()
{
    var processor = new DataProcessor();
    processor.ProcessData(largeDataSet);
}
Для работы с большими наборами тестовых данных удобно использовать внешние источники. NUnit позволяет загружать данные из CSV, JSON и других форматов:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
[Test]
[TestCaseSource(typeof(TestDataProvider), nameof(TestDataProvider.LoadFromCsv))]
public void ProcessCustomer_ShouldCalculateCorrectDiscount(
    Customer customer, decimal expectedDiscount)
{
    var service = new DiscountService();
    var discount = service.CalculateDiscount(customer);
    Assert.AreEqual(expectedDiscount, discount);
}
 
public static class TestDataProvider
{
    public static IEnumerable<TestCaseData> LoadFromCsv()
    {
        using var reader = new StreamReader("TestData/customers.csv");
        string line;
        while ((line = reader.ReadLine()) != null)
        {
            var parts = line.Split(',');
            var customer = new Customer
            {
                Id = int.Parse(parts[0]),
                Name = parts[1],
                PurchaseAmount = decimal.Parse(parts[2])
            };
            var expectedDiscount = decimal.Parse(parts[3]);
            
            yield return new TestCaseData(customer, expectedDiscount)
                .SetName($"Customer {customer.Id}: {customer.Name}");
        }
    }
}
В крупных проектах я стакивался с необходимостью логирования деталей выполнения тестов. NUnit предоставляет свой механизм логирования через класс TestContext:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[Test]
public void ComplexOperation_ShouldLogProgress()
{
    var processor = new DataProcessor();
    
    TestContext.WriteLine("Starting data processing test");
    processor.OnProgress += (sender, progress) => 
        TestContext.WriteLine($"Progress: {progress}%");
    
    processor.ProcessData(largeDataSet);
    
    TestContext.WriteLine("Processing completed");
    Assert.That(processor.IsComplete);
}
Эти логи отображаются в окне результатов тестов и могут быть сохранены в файл для дальнейшего анализа.
Интересная возможность, которую я недавно открыл для себя - тестирование производительности. Хотя NUnit не является специализированным фреймворком для нагрузочного тестирования, он позволяет создавать простые тесты производительности:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[Test]
public void SearchAlgorithm_ShouldBeEfficient()
{
    var searcher = new Searcher();
    var largeArray = Enumerable.Range(1, 1000000).ToArray();
    
    // Выполняем поиск несколько раз для более точных измерений
    var stopwatch = new Stopwatch();
    stopwatch.Start();
    
    for (int i = 0; i < 100; i++)
    {
        searcher.Find(largeArray, 500000);
    }
    
    stopwatch.Stop();
    
    TestContext.WriteLine($"Search time: {stopwatch.ElapsedMilliseconds} ms");
    Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(200));
}
При создании модульных тестов часто возникает вопрос о границах тестирования - что считать "модулем"? В одном из проектов мы разработали подход с использованием маркеров для обозначения уровня теста:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public enum TestLevel
{
    Unit,
    Integration,
    System
}
 
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class TestLevelAttribute : PropertyAttribute
{
    public TestLevelAttribute(TestLevel level) 
        : base("Level", level) { }
}
 
[Test]
[TestLevel(TestLevel.Unit)]
public void SimpleCalculation_ShouldReturnCorrectResult()
{
    // Чистый модульный тест
}
 
[Test]
[TestLevel(TestLevel.Integration)]
public void DatabaseOperation_ShouldPersistData()
{
    // Интеграционный тест, требующий базу данных
}
Это позволяет фильтровать тесты по уровню при запуске:

Bash
1
nunit3-console tests.dll --where "Level == Unit"
В завершение раздела о продвинутых возможностях хочу отметить, что NUnit постоянно развивается. Последние версии фреймворка добавили поддержку фильтрации тестов по имени и категории непосредственно в коде:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[TestFixture]
public class TestSuite
{
    [TestCase("filter=cat==UnitTest")]
    public void RunSelectedTests(string filter)
    {
        var runner = new NUnitTestAssemblyRunner(new DefaultTestAssemblyBuilder());
        runner.Load(Assembly.GetExecutingAssembly(), new Dictionary<string, object>());
        
        var filterService = new TestFilterService();
        var testFilter = filterService.MakeTestFilter(filter);
        
        var result = runner.Run(null, testFilter);
        
        Console.WriteLine($"Passed: {result.PassCount}, Failed: {result.FailCount}");
    }
}
Такой подход позволяет динамически определять, какие тесты запускать, что особено полезно при создании собственных инструментов для тестирования.

Реальные сценарии использования



Тестирование бизнес-логики всегда было для меня самым важным применением NUnit. В реальных проектах большая часть ошибок скрывается именно в логике расчетов, обработке данных и алгоритмах принятия решений. Эти компоненты идеально подходят для юнит-тестирования, поскольку они обычно имеют четко определенные входы и выходы. Вот пример теста бизнес-логики для сервиса расчета скидок:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
[TestFixture]
public class DiscountServiceTests
{
    private DiscountService _service;
    private Mock<IProductRepository> _repositoryMock;
 
    [SetUp]
    public void Setup()
    {
        _repositoryMock = new Mock<IProductRepository>();
        _service = new DiscountService(_repositoryMock.Object);
    }
 
    [Test]
    [TestCase(999, 0.0)]        // До 1000 - нет скидки
    [TestCase(1000, 0.05)]      // От 1000 до 1999 - 5%
    [TestCase(1999, 0.05)]
    [TestCase(2000, 0.1)]       // От 2000 до 4999 - 10%
    [TestCase(4999, 0.1)]
    [TestCase(5000, 0.5)]       // От 5000 до 19999 - 50%
    [TestCase(19999, 0.5)]
    [TestCase(20000, 0.0)]      // От 20000 - нет скидки (спец. условие)
    public void CalculateDiscount_BasedOnAmount_ReturnsCorrectPercentage(
        double salesAmount, double expectedDiscountRate)
    {
        // Act
        double actualDiscountRate = _service.CalculateDiscountRate(salesAmount);
 
        // Assert
        Assert.AreEqual(expectedDiscountRate, actualDiscountRate, 0.001);
    }
 
    [Test]
    public void CalculateDiscount_NegativeAmount_ThrowsArgumentException()
    {
        // Assert
        Assert.Throws<ArgumentException>(() => _service.CalculateDiscountRate(-100));
    }
}
При тестировании веб-API особенно ценны интеграционные тесты. Здесь мы проверяем, что контроллеры правильно взаимодействуют с сервисами, авторизацией и базами данных. Я обычно делю такие тесты на две категории: "чистые" модульные тесты контроллеров с моками всех зависимостей и полноценные интеграционные тесты.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
[TestFixture]
public class ProductControllerTests
{
    private ProductController _controller;
    private Mock<IProductService> _serviceMock;
 
    [SetUp]
    public void Setup()
    {
        _serviceMock = new Mock<IProductService>();
        _controller = new ProductController(_serviceMock.Object);
    }
 
    [Test]
    public async Task GetProducts_ReturnsOkWithProducts()
    {
        // Arrange
        var products = new List<Product>
        {
            new Product { Id = 1, Name = "Product1", Price = 10.99m },
            new Product { Id = 2, Name = "Product2", Price = 20.99m }
        };
        _serviceMock.Setup(s => s.GetAllAsync()).ReturnsAsync(products);
 
        // Act
        var result = await _controller.GetProducts();
 
        // Assert
        var okResult = result as OkObjectResult;
        Assert.IsNotNull(okResult);
        var returnedProducts = okResult.Value as List<Product>;
        Assert.IsNotNull(returnedProducts);
        Assert.AreEqual(2, returnedProducts.Count);
    }
}
Для интеграционных тестов с настоящими базами данных я предпочитаю использовать встроенные или локальные базы. SQLite отлично подходит для тестирования логики с Entity Framework, а для NoSQL решений - встроенные версии MongoDB или RavenDB.

В одном из проектов мы столкнулись с непредсказуемым поведением API при одновременном доступе нескольких пользователей. Чтобы решить эту проблему, я написал набор параллельных интеграционных тестов:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
[TestFixture]
public class ConcurrentAccessTests
{
    private TestServer _server;
    private HttpClient _client;
    private DbContextOptions<AppDbContext> _dbOptions;
 
    [OneTimeSetUp]
    public void InitializeServer()
    {
        // Настройка тестового сервера и БД
        _dbOptions = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase("TestDb")
            .Options;
            
        // Заполняем тестовыми данными
        using (var context = new AppDbContext(_dbOptions))
        {
            context.Products.Add(new Product { Id = 1, Name = "Test", Stock = 10 });
            context.SaveChanges();
        }
            
        var webHostBuilder = new WebHostBuilder()
            .UseStartup<TestStartup>()
            .ConfigureServices(services => 
            {
                services.AddDbContext<AppDbContext>(options => 
                    options.UseInMemoryDatabase("TestDb"));
            });
            
        _server = new TestServer(webHostBuilder);
        _client = _server.CreateClient();
    }
 
    [Test]
    public async Task PurchaseProduct_ConcurrentAccess_ManagesStockCorrectly()
    {
        // Пять одновременных запросов на покупку 3 единиц товара
        // (всего в наличии 10, должно быть обработано не более 3 запросов)
        var tasks = Enumerable.Range(0, 5)
            .Select(_ => _client.PostAsync("/api/purchase", 
                new StringContent(
                    JsonConvert.SerializeObject(new { ProductId = 1, Quantity = 3 }),
                    Encoding.UTF8, 
                    "application/json")))
            .ToArray();
            
        await Task.WhenAll(tasks);
        
        // Проверяем результаты
        int successCount = tasks.Count(t => t.Result.IsSuccessStatusCode);
        
        // Проверяем остаток на складе
        using (var context = new AppDbContext(_dbOptions))
        {
            var product = await context.Products.FindAsync(1);
            Assert.IsNotNull(product);
            Assert.AreEqual(1, product.Stock); // 10 - (3*3) = 1
            Assert.AreEqual(3, successCount); // Только 3 запроса успешны
        }
    }
    
    [OneTimeTearDown]
    public void CleanupServer()
    {
        _client.Dispose();
        _server.Dispose();
    }
}
Этот тест выявил гонку условий в коде обработки заказов, что привело к отрицательным значениям на складе - ситуация, которая никогда не должна возникать. Я исправил проблему, добавив транзакционную блокировку на уровне базы данных.
Для тестирования производительности и нагрузки я обычно использую комбинацию NUnit с дополнительными инструментами. Вот пример простого нагрузочного теста:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[TestFixture]
public class PerformanceTests
{
    [Test]
    [Category("Performance")]
    public void SearchAlgorithm_LargeDataset_PerformsWithinThreshold()
    {
        // Arrange
        var dataSet = GenerateLargeDataSet(1000000);
        var searchEngine = new SearchEngine();
        var query = "specific rare term";
        
        // Act
        var sw = Stopwatch.StartNew();
        var results = searchEngine.Search(dataSet, query);
        sw.Stop();
        
        // Assert
        Assert.That(sw.ElapsedMilliseconds, Is.LessThan(100));
        TestContext.WriteLine($"Search completed in {sw.ElapsedMilliseconds}ms");
    }
    
    private List<Document> GenerateLargeDataSet(int count)
    {
        // Генерация тестовых данных
        return Enumerable.Range(0, count)
            .Select(i => new Document 
            { 
                Id = i, 
                Content = $"Document {i} content with some random words {(i % 1000 == 0 ? "specific rare term" : "")}" 
            })
            .ToList();
    }
}
В серьезных проектах интеграция тестов в CI/CD пайплайн критически важна. Я обычно настраиваю несколько типов тестовых запусков:
1. Быстрые модульные тесты, которые запускаются после каждого коммита.
2. Интеграционные тесты, выполняемые перед каждым мерджем.
3. Полные регрессионные тесты перед релизом.
Для Azure DevOps пайплайн выглядит примерно так:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
trigger:
main
 
pool:
  vmImage: 'ubuntu-latest'
 
variables:
  buildConfiguration: 'Release'
 
steps:
task: DotNetCoreCLI@2
  displayName: 'Build'
  inputs:
    command: 'build'
    projects: '**/*.csproj'
    arguments: '--configuration $(buildConfiguration)'
 
task: DotNetCoreCLI@2
  displayName: 'Run Unit Tests'
  inputs:
    command: 'test'
    projects: '**/*Tests.csproj'
    arguments: '--configuration $(buildConfiguration) --filter Category=Unit'
    publishTestResults: true
 
task: DotNetCoreCLI@2
  displayName: 'Run Integration Tests'
  condition: and(succeeded(), eq(variables['Build.SourceBranchName'], 'main'))
  inputs:
    command: 'test'
    projects: '**/*Tests.csproj'
    arguments: '--configuration $(buildConfiguration) --filter Category=Integration'
    publishTestResults: true
При работе с микросервисной архитектурой нередко сталкиваюсь с проблемой тестирования межсервисного взаимодействия. В одном проекте мы реализовали комплексный набор тестов, моделирующих сценарии обмена сообщениями между сервисами. Основная сложность заключалась в воспроизведении различных сбойных ситуаций - потери сообщений, недоступности сервисов, дублирования запросов.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
[TestFixture]
public class OrderProcessingIntegrationTests
{
    private TestMessageBus _messageBus;
    private OrderService _orderService;
    private PaymentServiceMock _paymentService;
    private DeliveryServiceMock _deliveryService;
 
    [SetUp]
    public void Setup()
    {
        _messageBus = new TestMessageBus();
        _paymentService = new PaymentServiceMock(_messageBus);
        _deliveryService = new DeliveryServiceMock(_messageBus);
        _orderService = new OrderService(_messageBus);
        
        // Регистрируем все сервисы на шине сообщений
        _messageBus.RegisterHandler<PaymentRequestMessage>(_paymentService.HandlePayment);
        _messageBus.RegisterHandler<DeliveryRequestMessage>(_deliveryService.HandleDelivery);
        _messageBus.RegisterHandler<PaymentCompletedMessage>(_orderService.HandlePaymentCompleted);
    }
 
    [Test]
    public async Task OrderProcessing_HappyPath_CompletesSuccessfully()
    {
        // Arrange
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = "customer1",
            Items = new List<OrderItem>
            {
                new OrderItem { ProductId = "product1", Quantity = 2, Price = 10.99m }
            },
            TotalAmount = 21.98m
        };
 
        // Act
        await _orderService.ProcessOrder(order);
        
        // Даем время на обработку всех сообщений
        await Task.Delay(100);
 
        // Assert
        Assert.AreEqual(OrderStatus.Completed, order.Status);
        Assert.IsTrue(_paymentService.ProcessedOrders.Contains(order.Id));
        Assert.IsTrue(_deliveryService.ProcessedOrders.Contains(order.Id));
    }
 
    [Test]
    public async Task OrderProcessing_PaymentFails_OrderIsCancelled()
    {
        // Arrange
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = "customer2",  // Этот клиент будет иметь проблемы с оплатой
            Items = new List<OrderItem>
            {
                new OrderItem { ProductId = "product1", Quantity = 2, Price = 10.99m }
            },
            TotalAmount = 21.98m
        };
        
        _paymentService.SetFailForCustomer("customer2");
 
        // Act
        await _orderService.ProcessOrder(order);
        
        // Даем время на обработку всех сообщений
        await Task.Delay(100);
 
        // Assert
        Assert.AreEqual(OrderStatus.Cancelled, order.Status);
        Assert.IsTrue(_paymentService.ProcessedOrders.Contains(order.Id));
        Assert.IsFalse(_deliveryService.ProcessedOrders.Contains(order.Id));
    }
}
Внедрение такой тестовой шины сообщений позволило нам моделировать различные сценарии взаимодействия между сервисами, не запуская реальную инфраструктуру. Мы даже смогли симулировать сетевые задержки и разрывы соединений.
В другом проекте мы столкнулись с необходимостью тестирования системы обработки больших обьемов данных. Классические модульные тесты не подходили из-за сложности создания репрезентативных наборов тестовых данных. Решением стало комбинирование NUnit с реальными, но уменьшенными копиями производственных данных:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
[TestFixture]
public class BigDataProcessingTests
{
    private DataProcessor _processor;
    private string _testDataPath;
 
    [OneTimeSetUp]
    public void GlobalSetup()
    {
        // Подготавливаем тестовые данные (скачиваем, распаковываем)
        _testDataPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
        Directory.CreateDirectory(_testDataPath);
        
        // Копируем тестовые данные из ресурсов
        ExtractTestDataset(_testDataPath);
        
        _processor = new DataProcessor();
    }
 
    [Test]
    [Category("BigData")]
    public void ProcessLargeDataset_ProducesCorrectResults()
    {
        // Arrange
        var inputFile = Path.Combine(_testDataPath, "sales_data.csv");
        var outputFile = Path.Combine(_testDataPath, "results.json");
        
        // Act
        var result = _processor.ProcessFile(inputFile, outputFile);
        
        // Assert
        Assert.IsTrue(result.Success);
        Assert.That(result.ProcessedRecords, Is.GreaterThan(10000));
        
        // Проверяем результаты обработки
        var processedData = JsonConvert.DeserializeObject<ProcessingResult>(
            File.ReadAllText(outputFile));
            
        Assert.AreEqual(1250345.67m, processedData.TotalSales, 0.01m);
        Assert.AreEqual(432, processedData.UniqueCustomers);
    }
    
    [OneTimeTearDown]
    public void GlobalCleanup()
    {
        // Удаляем временные данные
        if (Directory.Exists(_testDataPath))
            Directory.Delete(_testDataPath, true);
    }
    
    private void ExtractTestDataset(string targetPath)
    {
        // Код извлечения тестовых данных из ресурсов сборки
    }
}
Важный момент при работе с большими тестовыми наборами данных - правильная очистка ресурсов. Я всегда использую блоки [OneTimeTearDown] для удаления временных файлов и освобождения ресурсов.
Также я часто сталкиваюсь с необходимостью тестирования кода, зависящего от системного времени. Вот пример теста для системы расписания задач:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
[TestFixture]
public class SchedulerTests
{
    private Scheduler _scheduler;
    private MockTimeProvider _timeProvider;
 
    [SetUp]
    public void Setup()
    {
        _timeProvider = new MockTimeProvider(new DateTime(2023, 1, 1, 12, 0, 0));
        _scheduler = new Scheduler(_timeProvider);
    }
 
    [Test]
    public void DailyTask_ShouldRunOncePerDay()
    {
        // Arrange
        int executionCount = 0;
        _scheduler.ScheduleDaily("12:30", () => executionCount++);
        
        // Act - имитируем течение времени
        // Проверяем 12:29 - задача не должна выполниться
        _timeProvider.SetCurrentTime(new DateTime(2023, 1, 1, 12, 29, 0));
        _scheduler.CheckSchedule();
        Assert.AreEqual(0, executionCount);
        
        // Проверяем 12:30 - задача должна выполниться
        _timeProvider.SetCurrentTime(new DateTime(2023, 1, 1, 12, 30, 0));
        _scheduler.CheckSchedule();
        Assert.AreEqual(1, executionCount);
        
        // Проверяем 12:31 - задача не должна выполниться повторно
        _timeProvider.SetCurrentTime(new DateTime(2023, 1, 1, 12, 31, 0));
        _scheduler.CheckSchedule();
        Assert.AreEqual(1, executionCount);
        
        // Переходим на следующий день - задача должна выполниться снова
        _timeProvider.SetCurrentTime(new DateTime(2023, 1, 2, 12, 30, 0));
        _scheduler.CheckSchedule();
        Assert.AreEqual(2, executionCount);
    }
}
Такой подход позволяет тестировать временно-зависимый код без ожидания реального наступления определенного времени.
Отдельно хочу остановится на тестировании UI-компонентов. Хотя NUnit не является специализированным фреймворком для UI-тестирования, он может использоваться в сочетании с другими инструментами. Например, для WPF-приложений я использую TestStack.White:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[TestFixture]
public class MainWindowTests
{
    private Application _application;
    private Window _mainWindow;
 
    [SetUp]
    public void Setup()
    {
        // Запускаем тестируемое приложение
        _application = Application.Launch("MyApp.exe");
        _mainWindow = _application.GetWindow("Main Window", InitializeOption.NoCache);
    }
 
    [Test]
    public void CalculateButton_WhenClicked_DisplaysResult()
    {
        // Arrange
        var num1TextBox = _mainWindow.Get<TextBox>("Num1TextBox");
        var num2TextBox = _mainWindow.Get<TextBox>("Num2TextBox");
        var calculateButton = _mainWindow.Get<Button>("CalculateButton");
        var resultLabel = _mainWindow.Get<Label>("ResultLabel");
        
        // Act
        num1TextBox.Text = "5";
        num2TextBox.Text = "7";
        calculateButton.Click();
        
        // Assert
        Assert.AreEqual("Результат: 12", resultLabel.Text);
    }
 
    [TearDown]
    public void TearDown()
    {
        // Закрываем приложение после тестов
        if (_application != null)
            _application.Close();
    }
}
В заключение раздела о реальных сценариях использования NUnit хочу подчеркнуть важность адаптации тестовых подходов к конкретным требованиям проекта. Не существует универсального шаблона тестирования, который подходил бы для всех случаев. Иногда лучше написать несколько интеграционных тестов, чем сотни модульных, особено если компоненты тесно связаны между собой.

Заключение



NUnit превосходит многие другие фреймворки своей универсальностью. Вы можете начать с простых модульных тестов, а по мере роста проекта добавлять параметризацию, асинхронность, категоризацию и даже производительностные тесты - всё в рамках одного инструмента. Экосистема NUnit постоянно развивается, добавляются новые возможности, улучшается интеграция с другими инструментами. Из собственного опыта могу сказать, что тестирование кардинально меняет подход к проектированию. Код, написанный с учетом его тестируемости, обычно получается более модульным, с четкими границами ответственности и минимумом скрытых зависимостей. Как говорится, хороший дизайн можно узнать по тому, насколько легко его тестировать.

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

NUnit test
Помогите сделать тестирование программы на nUnit. За ранее спасибо.

Использование NUnit
Здравствуйте. Пробую протестировать soap-сервис. Нужно использовать NUnit. К сожалению в с# не...

Тестирование GUI интерфейса (NUnit.Forms)
Здравствуйте! Пытаюсь написать несколько тестов для тестирование GUI интерфейса. Для работы решил...

NUnit+MSTest+work
Такой вопрос. Есть у меня решение, в нем проект с классами/методами, есть проекты с тестами (MS,...

NUnit тест
Добрый вечер. Получил задание написать программу и к ней тесты(через NUnit).Программа должна...

Написание unit test на nunit, который вернет объект
Доброго времени суток. Прошу помощи в решении проблемы. Необходимо реализовать юнит тест на...

Что делает код? (NSubstitute, NUnit)
Есть пример по использованию NSubstitute для стаб/мок тестирования. В данном примере создается...

[NUnit 3] Как правильно проверить методы работы с базой данных?
Здравствуйте. Пишу приложение по работе с БД на базе MSSQL CE. Написал библиотеку по работе с БД....

NUnit проверка свойств
Объект создается статической функций, так как надо проводить валидацию и прочее. Возможно ли...

NUnit. Тест не выполняется и зависает
При выполнении теста StartServer_localhost__8181__ws_expectNoThrows ничего не происходит, прогресс...

NUnit. Не работает атрибут [SetUp]
Столкнулся с проблемой - не работает атрибут SetUp В идеале, перед каждым тест методом, должен...

Запуск NUnit теста из dll
Подскажите пожалуйста, как запустить NUnit тесты из dll библиотеки? У меня есть проект с готовыми...

Метки .net, c#, mock, moq, nunit, unit-test
Размещено в Без категории
Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
Мастер-класс по микросервисам на Node.js
Reangularity 21.06.2025
Node. js стал одной из самых популярных платформ для микросервисной архитектуры не случайно. Его неблокирующая однопоточная модель и событийно-ориентированный подход делают его идеальным для. . .
Управление Arduino из WPF приложения
Wired 21.06.2025
Зачем вообще связывать Arduino с WPF-приложением? Казалось бы, у Arduino есть собственная среда разработки, своя экосистема, свои способы управления. Однако при создании серьезных проектов. . .
Звёздная пыль
kumehtar 20.06.2025
Я просто это себе представляю: как создавался этот мир. Как энергия слипалась в маленькие частички. Как они собирались в первые звёзды, как во вселенной впервые появился Свет. Как эти звёзды. . .
Создание нейросети с PyTorch
AI_Generated 19.06.2025
Ключевое преимущество PyTorch — его питоновская натура. В отличие от TensorFlow, который изначально был построен как статический вычислительный граф, PyTorch предлагает динамический подход. Это. . .
JWT аутентификация в ASP.NET Core
UnmanagedCoder 18.06.2025
Разрабатывая веб-приложения, я постоянно сталкиваюсь с дилеммой: как обеспечить надежную аутентификацию пользователей без ущерба для производительности и масштабируемости? Классические подходы на. . .
Краткий курс по С#
aaLeXAA 18.06.2025
Здесь вы найдете все необходимые функции чтоб написать програму на C# Задание 1: КЛАСС FORM 1 public partial class Form1 : Form { Spisok listin = new Spisok(); . . .
50 самых полезных примеров кода Python для частых задач
py-thonny 17.06.2025
Эффективность работы разработчика часто измеряется не количеством написаных строк, а скоростью решения задач. Готовые сниппеты значительно ускоряют разработку, помогают избежать типичных ошибок и. . .
C# и продвинутые приемы работы с БД
stackOverflow 17.06.2025
Каждый . NET разработчик рано или поздно сталкивается с ситуацией, когда привычные методы работы с базами данных превращаются в источник бессонных ночей. Я сам неоднократно попадал в такие ситуации,. . .
Angular: Вопросы и ответы на собеседовании
Reangularity 15.06.2025
Готовишься к техническому интервью по Angular? Я собрал самые распространенные вопросы, с которыми сталкиваются разработчики на собеседованиях в этом году. От базовых концепций до продвинутых. . .
Архитектура Onion в ASP.NET Core MVC
stackOverflow 15.06.2025
Что такое эта "луковая" архитектура? Термин предложил Джеффри Палермо (Jeffrey Palermo) в 2008 году, и с тех пор подход только набирал обороты. Суть проста - представьте себе лук с его. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2025, CyberForum.ru