Мистецтво юніт-тестування в .NET
Привіт мене звати Олесь. Я Senior .NET розробник в компанії «Stepico». Сьогодні я хотів би поговорити про доволі неоднозначну тему серед розробників — юніт-тестування.
Навіщо нам потрібне юніт-тестування
Спершу розберімось, які бувають види автоматичних тестів в процесі розробки. Я виділив 3 види, але в залежності від процесу, можуть бути й інші.
- UI тести: найдорожчі за ресурсами і часом виконання тести, близькі до реальної взаємодії клієнта і програмного продукту.
- Інтеграційні тести (чи енд то енд в залежності від проєкту): дешевші за ресурсами і часом, покривають взаємодію між кількома компонентами системи чи між клієнтом і сервером.
- Юніт-тестування: найдешевші за ресурсами і часом, покривають роботу одного юніта коду, чи то клас, чи метод, не залежать від інших компонентів системи.
Як видно, юніт-тести є базовою частиною піраміди тестування, їх легко писати і підтримувати, можна використовувати як один з етапів СI/CD пайплайну, який не буде затримувати в процесі розробки. І, що найголовніше, як мені здається, написання і підтримання юніт-тестів підштовхує до розробки менш пов’язаних частин коду між собою, що своєю чергою зробить зміни кода набагато легшими і дешевшими.
Юніт-тест фреймворки в .NET
Найпопулярніші фреймворки юніт-тестування в .NET:
Я вважаю, що в сучасній розробці варто приділити увагу XUnit, ось чому:
- NUnit — застарілий фреймворк. Який спочатку був портований з JUnit фреймворка на Java.
- XUnit дозволяє запускати тести паралельно, чого не можна зробити з NUnit.
- Для запуску 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.
Дякую за увагу, пишіть тести та донадте на ЗСУ. ВСЕ БУДЕ УКРАЇНА!
21 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів