Мистецтво юніт-тестування в .NET

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Привіт мене звати Олесь. Я Senior .NET розробник в компанії «Stepico». Сьогодні я хотів би поговорити про доволі неоднозначну тему серед розробників — юніт-тестування.

Навіщо нам потрібне юніт-тестування

Спершу розберімось, які бувають види автоматичних тестів в процесі розробки. Я виділив 3 види, але в залежності від процесу, можуть бути й інші.

  • UI тести: найдорожчі за ресурсами і часом виконання тести, близькі до реальної взаємодії клієнта і програмного продукту.
  • Інтеграційні тести (чи енд то енд в залежності від проєкту): дешевші за ресурсами і часом, покривають взаємодію між кількома компонентами системи чи між клієнтом і сервером.
  • Юніт-тестування: найдешевші за ресурсами і часом, покривають роботу одного юніта коду, чи то клас, чи метод, не залежать від інших компонентів системи.

Як видно, юніт-тести є базовою частиною піраміди тестування, їх легко писати і підтримувати, можна використовувати як один з етапів СI/CD пайплайну, який не буде затримувати в процесі розробки. І, що найголовніше, як мені здається, написання і підтримання юніт-тестів підштовхує до розробки менш пов’язаних частин коду між собою, що своєю чергою зробить зміни кода набагато легшими і дешевшими.

Юніт-тест фреймворки в .NET

Найпопулярніші фреймворки юніт-тестування в .NET:

Я вважаю, що в сучасній розробці варто приділити увагу XUnit, ось чому:

  1. NUnit — застарілий фреймворк. Який спочатку був портований з JUnit фреймворка на Java.
  2. XUnit дозволяє запускати тести паралельно, чого не можна зробити з NUnit.
  3. Для запуску NUnit тестів на СI/CD пайплайні потрібно мати встановлений NUnit тест ранер, водночас XUnit тести можна запускати просто через .NET CLI.

Бібліотеки, які допомагають в написанні юніт-тестів

  • NSubstitute. Бібліотека, яка допомагає підмінювати залежності в коді, щоб тестувати частини кода в ізоляції.
  • Fluentassertions. Бібліотека, яка допомагає писати судження (assertion) в плавному (fluent) вигляді.
  • AutoFixture.Xunit2. Бібліотека, яка допомагає в додаванні автоматичних даних в параметри тестового метода.

ААА структура тестового метода

ААА структура тестового метода складається з трьох частин:

  • Arrange — частина метода, де створюються всі тестові дані.
  • Act — частина тестового метода, де відбувається виклик метода, який ми тестуємо.
  • Assert — частина метода, де відбуваються всі тестові перевірки.

Всі частини можна побачити на прикладі нижче:

[Fact]
public void GetSomeValue_ValueMoreThanFive_ReturnFive()
{
    // Arrange
    const string someString = "SomeString";

    // Act
    var result = _example.GetSomeValue(someString);

    // Assert
    result.Should().Be(5);
}

Типи юніт-тестів

Розберімо на прикладах, які бувають типи юніт-тестів.

Тестування даних, які повертаються з методу

У нас є метод, який при виконанні деяких умов повертає одне чи інше значення:

public int GetSomeValue(string value)
{
    const int someInt = 5;

    return value.Length > someInt ? someInt : 0;
}

Розберімо тести, які можна написати на цей метод:

[Fact]
public void GetSomeValue_ValueMoreThanFive_ReturnFive()
{
    // Arrange
    const string someString = "SomeString";

    // Act
    var result = _example.GetSomeValue(someString);

    // Assert
    result.Should().Be(5);
}

В цьому тест-методі використовується атрибут [Fact], який означає, що ми не передаємо жодних аргументів в тест метод.

Структура тесту була описана в минулому пункті.

Ми передаємо стрічку «SomeString» в наш метод і очікуємо, що результат його роботи буде «5», оскільки в нашому методі є умова на довжину стрічки.

[Theory]
[InlineData("some", 0)]
public void GetSomeValue_ValueLessThanFive_ReturnZero(string value, int resultValue)
{
    // Arrange
    // Act
    var result = _example.GetSomeValue(value);

    // Assert
    result.Should().Be(resultValue);
}

В цьому тест методі використовується інший атрибут [InlineData("some«, 0)], який дає можливість передавати тестові дані через аргументи метода. Оскільки в блоці «Arrange» не створюється жодних даних, то його можна як прибрати, так і залишити пустим.
Ми передаємо через аргументи стрічку «some» і результуюче значення «0», оскільки в нашому методі є умова на довжину стрічки.

Тестування зміни стейту

У нас є проперті класу «State» і метод «ChangeStateAsync», який його змінює, а також поле типу «IDependency», яке ми отримуємо через конструктор класа.

private readonly IDependency _dependency;

public UnitTestExample(IDependency dependency)
{
     _dependency = dependency;
}
        
public int State { get; private set; }

public async Task ChangeStateAsync(int newStateValue)
{
    const int delay = 2;

    State = _dependency.InitialState;

    await Task.Delay(delay);

    if (State != 0)
        return;

    State = newStateValue;
}

Розберімо тести, які можна написати на даний метод.

[Theory]
[InlineAutoData(0)]
public async Task ChangeStateAsync_ShouldSetNewState(int dependencyState, int state)
{
    // Arrange
    _dependency.InitialState.Returns(dependencyState);

    // Act
    await _example.ChangeStateAsync(state);

    // Assert
    _example.State.Should().Be(state);
}

В даному тесті ми використовуємо «InlineAutoData» атрибут, який дозволяє нам задати лише один аргумент метода, а інші будуть додані автоматично, завдяки описаній вище бібліотеці «AutoFixture.Xunit2».

Також в сигнатурі метода ми використовуємо «Task» як тип, що повертається, а також додаємо ключове слово «async», щоб тестувати асинхронний метод. Це важливо не забувати при написанні тестів на асинхронні методи. В блоці «Arrange» ми підставляємо значення проперті, яке поверне наша залежність, — це ми можемо, тому що використовуємо бібліотеку «NSubstitute » в конструкторі тестового класу, коли додаємо залежність.

public UnitTestExampleTests()
{
    _dependency = Substitute.For<IDependency>();
    _example = new UnitTestExample(_dependency);
}

Підставив потрібне нам значення «dependencyState = 0» після виклику метода, який ми тестуємо, перевіряємо, що проперті нашого класу змінилось на значення, яке ми передали в метод.

Ще один тест на цей метод, але вже з іншим флоу:

[Theory]
[AutoData]
public async Task ChangeStateAsync_ShouldSetStateByDependency(
int dependencyState, int state)
{
    // Arrange
     _dependency.InitialState.Returns(dependencyState);

    // Act
    await _example.ChangeStateAsync(state);

    // Assert
    _example.State.Should().Be(dependencyState);
}

В цьому тесті ми використали атрибут «AutoData», який дозволяє нам автоматично додавати аргументи в наш тестовий метод. Позаяк автоматичне створення значення аргументу метода «state» не буде дорівнювати «0», то значення проперті нашого класу не зміниться, і цю умову ми відображаємо в блоці «Assert» нашого тесту.

Тестування виклику метода

У нас є метод, який при виконанні деяких умов викликає метод поля нашого класу _dependency.

public void CallDependency(bool isCall, string dependencyArg)
{
    if (isCall)
    {
        _dependency.DependencyMethod(dependencyArg);
    }
}

Подивімось, які тести можна написати на цей метод.

[Theory]
[InlineAutoData(true)]
public void CallDependency_ShouldCallDependencyMethod(bool isCall, 
string dependencyArg)
{
    // Arrange
    // Act
    _example.CallDependency(isCall, dependencyArg);

    // Assert
    _dependency.Received().DependencyMethod(dependencyArg);
}

В даному тесті ми використовуємо атрибути, які були описані вище. Позаяк нам не треба створювати тестових даних, тому блок «Arrange» ми залишаємо пустим. В блоці «Act» ми викликаємо метод, який хочемо протестувати з параметрами, які ми отримали завдяки «InlineAutoData», насамперед «isCall = true». І в блоці «Assert» перевіряємо, щоб потрібний метод «DependencyMethod» був викликаний. Для цього використовуємо метод «Received» з бібліотеки «Fluentassertions».

І другий тест, в якому при невиконанні умови наш метод не буде викликаний:

[Theory]
[InlineAutoData(false)]
public void CallDependency_ShouldNotCallDependencyMethod(bool  isCall, 
string dependencyArg)
{
    // Arrange
    // Act
    _example.CallDependency(isCall, dependencyArg);

    // Assert
    _dependency.DidNotReceive().DependencyMethod(dependencyArg);
}

В цьому тесті ми використовуємо метод «DidNotReceive» з бібліотеки «Fluentassertions».

Тестування прокидання виключень

public void MethodWithThrowException()
{
    if (!_dependency.DependencyExpression())
    {
        return;
    }

    throw new Exception();
}

В цьому методі при виконанні деяких умов буде присутнє виключення «Exception». Подивімось на тести, які можна написати на даний метод.

[Theory]
[InlineData(true)]
public void MethodWithThrowException_ShouldThrowException(bool expression)
{
    // Arrange
    _dependency.DependencyExpression().Returns(expression);

    // Act
    var action = () => _example.MethodWithThrowException();

    // Assert
    action.Should().Throw<Exception>();
}

З нового в цьому тесті можна побачити створення делегата типу «Action» в блоці «Act». І в блоці «Assert» за допомогою конструкції «Should().Throw<Exception>()» перевіряється, що в даному методі буде виключення.

[Theory]
[InlineData(false)]
public void MethodWithThrowException_ShouldNotThrowException(bool expression)
{
    // Arrange
    _dependency.DependencyExpression().Returns(expression);

    // Act
    var action = () => _example.MethodWithThrowException();

    // Assert
    action.Should().NotThrow<Exception>();
}

І ось інший тест, в якому перевіряється, що виключення не буде виникати при виконанні даного методу.

Способи тестування методів, в яких використовується статика

Однією з найбільших проблем при тестуванні функціональності є використання статичних викликів, класів або методів. Проблемою вони є, бо зазвичай не дають можливості підмінити цю функціональність для тестування, і через це потребують чи додаткового рівня створення тестових даних, чи взагалі нівелюють можливість тестувати цю функціональність.

Погляньмо на прикладі:

public class StaticExample
{
    private const double ConstValue = double.Epsilon;

    public double Execute(bool getRandom)
    {
	return getRandom ? Static.GetRandom() : ConstValue;
    }
}

Ми маємо метод «Execute» і хочемо написати на нього тести, але в такому випадку в нас це не вийде, тому що ми не можемо підмінити статичний виклик «Static.GetRandom()». Є декілька способів, як провести рефакторинг даного метода, щоб його стало можливо тестувати.

Виділити і підмінити

Перший варіант рефакторинга цього метода — винести функціональність, яка заважає тестувати, в окремий віртуальний метод, і потім його підмінити при написанні тестів.

Ось як це виглядає в коді:

public class StaticExampleWithOverride
{
    private const double ConstValue = double.Epsilon;

    public double Execute(bool getRandom)
    {
        return getRandom ? GetRandom() : ConstValue;
    }

    public virtual double GetRandom()
    {
        return Static.GetRandom();
    }
 }

І також тести на цей метод:

[Theory]
[InlineAutoData(true)]
public void Execute_ReturnRandomValue(bool getRandom, 
double  getRandomValue)
{
    // Arrange
    var staticExample = new StaticExampleWithOverrideMock(getRandomValue);

    // Act
    var result = staticExample.Execute(getRandom);

    // Assert
    result.Should().Be(getRandomValue);
}

[Theory]
[InlineAutoData(false)]
public void Execute_ReturnDoubleEpsilon(bool getRandom, 
double getRandomValue)
{
    // Arrange
    var staticExample = new StaticExampleWithOverrideMock(getRandomValue);

    // Act
    var result = staticExample.Execute(getRandom);
          
    // Assert
    result.Should().Be(Double.Epsilon);
}

private class StaticExampleWithOverrideMock : StaticExampleWithOverride
{
    private readonly double _getRandomValue;

    public StaticExampleWithOverrideMock(double getRandomValue)
    {
        _getRandomValue = getRandomValue;
    }

    public override double GetRandom()
    {
        return _getRandomValue;
    }
 }

Винести в залежність

Інший варіант рефакторинга — винесення коду в окрему залежність, яку можна отримати через конструктор. Цей варіант я вважаю найоптимальнішим, бо ми отримуємо можливість не лише написати тести, а й отримати більш гнучку архітектуру коду.

Цей рефакторинг в коді:

public class StaticExampleWithDependency
{
    private const double ConstValue = double.Epsilon;

    private readonly IRandomDependency _dependency;

    public StaticExampleWithDependency(IRandomDependency dependency)
    {
         _dependency = dependency;
    }

    public double Execute(bool getRandom)
    {
        return getRandom ? _dependency.GetRandom() : ConstValue;
    }
}

    
public interface IRandomDependency
{
    double GetRandom();
}

public class RandomDependency : IRandomDependency
{
    public double GetRandom()
    {
        return Static.GetRandom();
    }
}

І також тест:

[Theory]
[InlineAutoData(true, 5.0, 5.0)]
[InlineAutoData(false, 5.0, 4.9406564584124654E-324)]
public void Execute_ReturnRandomValue(bool getRandom, double  getRandomValue, double resultValue)
{
    // Arrange
    var dependency = Substitute.For<IRandomDependency>();
    dependency.GetRandom().Returns(getRandomValue);
    var staticExample = new StaticExampleWithDependency(dependency);

    // Act
    var result = staticExample.Execute(getRandom);

    // Assert
    result.Should().Be(resultValue);
}

І наостанок декілька книжок, які допоможуть краще розібратись з написанням тестів і підміною залежностей:

  • «The Art Of Unit Testing» by Roy Osherove.
  • «Dependency Injection In .NET» by Mark Seemann.

Дякую за увагу, пишіть тести та донадте на ЗСУ. ВСЕ БУДЕ УКРАЇНА!

👍ПодобаєтьсяСподобалось16
До обраногоВ обраному9
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Інтеграційні тести (чи енд то енд в залежності від проєкту): дешевші за ресурсами і часом, покривають взаємодію між кількома компонентами системи чи між клієнтом і сервером.

Не знаю, може в .NET інша термінологія, але в Java є інтеграційні тести (які, наприклад, тестують взаємодію двох компонентів додатка), а є системні тести, які тестують систему цілком.

_getRandomValue
_dependency
_example

_в _англійському _алфавіті _26 _літер, _всі _можна _використовувати _для _назви _змінних _і _другувати _натисканням _однієї _клавіші _на _клавіатурі. _В _англійському _словнику _десь _150-_300 _тисяч _слів. _Ще _натиснувши _одну _зайву _клавішу _можна _додати _26 _великих _літер _і _зробити _виразним _прочитання _для _комбінацій _слів.

underscore заміняє знаки пунктуації, для відокремлювання категорій.

Execute_ReturnRandomValue

Метод_Кейс

Згодний, це underscore здорової людини.

XUnit дозволяє запускати тести паралельно, чого не можна зробити з NUnit.

docs.nunit.org/...​butes/parallelizable.html

Для запуску NUnit тестів на СI/CD пайплайні потрібно мати встановлений NUnit тест ранер, водночас XUnit тести можна запускати просто через .NET CLI.

нет, можно запустить через dotnet test, но фич ранера, естественно, не будет. Раннер — не абуза, а преимущество

Я виділив 3 види, але в залежності від процесу, можуть бути й інші.

юай тестирование это ты не туда, классически в автоматизированное тестирование выделяются юнит, интеграционное и системное тестирование

Забули написати як перевіряти покриття коду тестами. Без цього важко зрозуміти скільки тестів потрібно, аби покрити усі сценарії.

можно використовувати
www.jetbrains.com/dotcover

нормальний код стайл, щоб розділяти поля від локальних змінних методу

Коментар порушує правила спільноти і видалений модераторами.

Коментар порушує правила спільноти і видалений модераторами.

Коментар порушує правила спільноти і видалений модераторами.

Мистецтво це трохи інше. У вас описаний апі бібліотеки для тестування. А мистецтво це більше про користь від тих тестів — що їх просто писати, розуміти, вони падають коли треба просигналізувати про невідповідність вимогам та завжди очікувано(детерміновано), не потребують переписувати ці тести після будь якої зміни коду і т.д. Зазвичай працювати складніше за все з цим.

можете сформулювати які саме кейси вам цікаві я подумаю над продовженням саттті) а назва статті це більше дань книзі The Art Of Unit Testing" by Roy Osherove яка колись дала мені багато по цій темі.

Хочу додати що до ААА.
Expected value також краще розташовувати у блоці Arrange.

Так легше сприйняти сенс теста: назва кейса дає інформацію щодо логиці, а перші строки одразу кажуть як дані трансформуються. Це також робить написання консистентним щодо сітуации коли expectedValue вказано в атрибуті.

Наприклад:

[Fact]
public void GetSomeValue_ValueMoreThanFive_ReturnFive()
{
// Arrange
const string someString = “SomeString”;
var expectedResult = 5;

// Act
var result = _example.GetSomeValue(someString);

// Assert
result.Should().Be(expectedResult);
}

[Theory]
[InlineData("some", 0)]
public void GetSomeValue_ValueMoreThanFive_ReturnFive(string someString, int expectedResult)
{
// Act
var result = _example.GetSomeValue(someString);

// Assert
result.Should().Be(expectedResult);
}

А вот моя заметка с критикой FluentAssertions: habr.com/...​omments/#comment_24797866

Виглядає прикольно в поєднанні з

AutoFixture

. Але чи змогли ви його завезти на .NET6?

так, не було ніяких питань з цим, навіть спеціально проект відкрив глянув)

ще у Владіміра Хорікова гарна книжка по темі є
enterprisecraftsmanship.com/book

Хорошая книжка. А вот еще подкаст с участием ее автора: sdcast.ksdaemon.ru/2020/12/sdcast-126

Дякую за статтю. Лаконічно й по суті. Пиши ще :)

Підписатись на коментарі