Мистецтво юніт-тестування в .NET
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Привіт мене звати Олесь. Я 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 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів