Тестове с xUnit
Накратко: Unit тестът е код, който проверява едно малко парче, обикновено един метод, при всяка промяна; в C# се пише с xUnit, [Fact] и Assert.Equal.
Проблем
Отстъпката, която се счупи тихо
Правилото на магазина е: отстъпката е в проценти, а при покупка от 100 € нагоре се добавят още 5%. Пишеш метода и го пробваш на ръка:
class Discount{ // Отстъпка в проценти. От 100 € нагоре се добавят още 5%. public static double Apply(double price, int percent) { if (price > 100) { percent = percent + 5; } return price * (100 - percent) / 100; }}
Console.WriteLine(Discount.Apply(50, 10));Console.WriteLine(Discount.Apply(200, 10));4517050 € с 10% са 45 €, а 200 € с 15% са 170 €. Изглежда наред и го пускаш. След седмица клиент с покупка за точно 100 € плаща 90 € вместо 85 €. Условието е price > 100, а трябва да е price >= 100.
Никой не е пробвал точно 100 €. А при всяка следваща промяна в метода трябва да пробваш пак всички случаи на ръка.
Идеята
Код, който проверява код
Вместо да гледаш изхода с очите си, пишеш малки методи, които викат твоя код и сравняват резултата с очаквания. Това е тест. Пускаш всички тестове с едно натискане, след всяка промяна, и те казват кое се е счупило.
| Вид | Какво проверява | Пример |
|---|---|---|
| Unit тест | едно малко парче, обикновено един метод, отделно от останалото | Discount.Apply(100, 10) връща 85 |
| Интеграционен | как парчетата работят заедно: с база данни, файл, друга услуга | поръчката се записва в базата и се чете обратно |
| End-to-end | цялото приложение, както го ползва човек | клиентът слага в количката, плаща и получава имейл |
Защо си струва:
- грешката се вижда веднага, докато още помниш какво си променил, а не когато клиентът се оплаче;
- тестът показва как се ползва кодът: кой случай какво връща;
- можеш да подреждаш кода смело. Ако тестовете са зелени и след промяната, поведението не се е променило.
По-точно, за любопитните
Има и други видове тестове: функционални (отговаря ли програмата на изискванията), за производителност (колко заявки издържа), за сигурност (има ли пробиви) и на потребителския интерфейс (бутоните, формите). Всички те стъпват на unit тестовете: те са най-много, най-бързи и най-евтини за поддържане.
Освен xUnit в .NET ще срещнеш NUnit ([Test], [SetUp]) и MSTest ([TestMethod], [TestInitialize]). Идеите са същите. Тук ползваме xUnit: с него е и шаблонът dotnet new xunit.Виж го
Първият тест
Discount са в отделен клас DiscountTest, в отделен проект за тестове:using Xunit;
public class DiscountTest{ [Fact] public void TenPercentOff() { Assert.Equal(45.0, Discount.Apply(50, 10)); }
[Fact] public void ExtraFivePercentFrom100() { Assert.Equal(85.0, Discount.Apply(100, 10)); }}[Fact]пред метода казва на xUnit „това е тест, пусни го“. Такива етикети в квадратни скоби се казват атрибути. Как xUnit ги намира, ще видим в последния урок на модула.- Тестовият клас е
public: xUnit пуска тестове само от публични класове.using Xunit;най-отгоре даваFactиAssert. Assert.Equal(очаквано, истинско)сравнява двете стойности. Ако се различават, тестът пада.
Пускаме двата теста срещу кода от първата карта:
ОК TenPercentOffПАДА ExtraFivePercentFrom100 Assert.Equal() Failure: Values differ Expected: 85 Actual: 90Тестове: 2, минали: 1, паднали: 1Първият тест минава. Вторият пада и казва точно какво е станало: очаквал е 85, а методът е върнал 90. В изхода на xUnit очакваното е Expected, а върнатото е Actual. Поправяш условието на price >= 100, пускаш пак и двата са зелени:
ОК ExtraFivePercentFrom100ОК TenPercentOffТестове: 2, минали: 2, паднали: 0Всеки тест прави три неща: подготвя стойностите, вика кода и проверява резултата.
public class DiscountTest{ [Fact] public void TenPercentOff() { Assert.Equal(45.0, Discount.Apply(50, 10)); }
[Fact] public void ExtraFivePercentFrom100() { Assert.Equal(85.0, Discount.Apply(100, 10)); }}Стъпка по стъпка, с думи
- DiscountTest има два теста. xUnit пуска всеки поотделно и пали лампичка до него.
- Тест 1, подготовка: цена 50 € и 10% отстъпка.
- Действие: Discount.Apply(50, 10) връща 45.
- Проверка: Assert.Equal сравнява очакваното 45 с върнатото 45. Еднакви са, тестът минава.
- Тест 2: точно 100 € и 10%. Очакваме 85, защото от 100 € нагоре има още 5%.
- Условието в метода е price > 100, а 100 не е по-голямо от 100. Методът връща 90.
- Очаквано 85, върнато 90: тестът пада и казва точно това.
- Поправяш условието на price >= 100 и пускаш тестовете пак.
- Сега 100 минава условието и методът връща 85.
- Очаквано и върнато съвпадат. И двата теста са зелени.
По-точно, за любопитните
- В истински проект тестовете са в отделен проект до кода, например
Shop.TestsдоShop. Visual Studio и Rider показват всички тестове в Test Explorer: натискаш „Run“ и виждаш резултата.
Shop/Discount.cs кодътShop/Shop.csprojShop.Tests/DiscountTest.cs тестовете за негоShop.Tests/Shop.Tests.csproj тук се добавя xUnit- Проектът за тестове се прави с
dotnet new xunit, аdotnet testго строи и пуска тестовете:
dotnet new xunit -o Shop.Tests нов проект за тестовеdotnet add Shop.Tests reference Shop тестовете виждат кодаdotnet test строи и пуска всички тестовеdotnet test --filter "FullyQualifiedName~Discount" само тестовете с Discount в името- xUnit е пакет в
.csprojфайла на тестовия проект. Шаблонът добавя пакетите, аdotnet add referenceдобавя връзката към кода:
<ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /></ItemGroup><ItemGroup> <ProjectReference Include="../Shop/Shop.csproj" /></ItemGroup>- Тук, на сайта, тестовете и
Mainса в една програма.Mainпуска тестовете през програмния интерфейс на xUnit (AssemblyRunnerот пакета xunit.runner.utility), един по един и с името на метода без класа, и печата резултатите, подредени по име. Подредени са, защото xUnit не обещава реда на тестовете. Съобщенията за грешка са на xUnit, отместени под името на теста:
using System.Reflection;using Xunit;using Xunit.Runners;
class Program{ static void Main() { RunTests(); }
// Пуска тестовете от това изпълнимо един по един и печата резултата, подреден по име. static void RunTests(string category = null) { var results = new List<string>(); int failed = 0; using var runner = AssemblyRunner.WithoutAppDomain(Assembly.GetExecutingAssembly().Location); if (category != null) { runner.TestCaseFilter = test => test.Traits.TryGetValue("Category", out var values) && values.Contains(category); } runner.OnTestPassed = test => results.Add("ОК " + test.TestDisplayName); runner.OnTestFailed = test => { failed++; results.Add("ПАДА " + test.TestDisplayName + "\n " + test.ExceptionMessage.ReplaceLineEndings("\n ")); }; runner.Start(new AssemblyRunnerStartOptions { Parallel = false, MethodDisplay = TestMethodDisplay.Method }); while (runner.Status != AssemblyRunnerStatus.Idle) { Thread.Sleep(10); } results.Sort(string.CompareOrdinal); foreach (string result in results) { Console.WriteLine(result); } Console.WriteLine("Тестове: " + results.Count + ", минали: " + (results.Count - failed) + ", паднали: " + failed); }}Пробвай
ДДС с double
Цената с ДДС е цената по 1,2. Два теста:
Какво ще покажат тестовете?
class Vat{ public static double WithVat(double price) { return price * 1.2; }}
using Xunit;
public class VatTest{ [Fact] public void Coffee() { Assert.Equal(12.0, Vat.WithVat(10)); }
[Fact] public void Tea() { Assert.Equal(3.6, Vat.WithVat(3)); }}3 по 1,2 в double не е точно 3,6. Дробните числа се пазят в двоичен вид и при сметките се губи малка част. Assert.Equal за double сравнява точно, затова тестът пада:
ОК CoffeeПАДА Tea Assert.Equal() Failure: Values differ Expected: 3.6000000000000001 Actual: 3.5999999999999996Тестове: 2, минали: 1, паднали: 1double не е точно 3,6. За дробни числа има трети аргумент: допустима разлика. Assert.Equal(3.6, Vat.WithVat(3), 0.001) минава, защото разликата е под 0,001. Внимавай с цяло число на това място: Assert.Equal(3.6, Vat.WithVat(3), 2) значи „закръгли и двете до 2 знака след запетаята“.Идеята
Да или не, и очаквано изключение
Освен равенство често проверяваш условие или че кодът отказва. Ето Account от модула за изключенията, с нов метод CanPay:
class InsufficientFundsException : Exception{ public InsufficientFundsException(int shortfall) : base("Не стигат " + shortfall + " €") { }}
class Account{ public int Balance { get; private set; }
public Account(int balance) { Balance = balance; }
public bool CanPay(int amount) { return amount <= Balance; }
public void Withdraw(int amount) { if (amount <= 0) { throw new ArgumentException("Сумата трябва да е положителна"); } if (amount > Balance) { throw new InsufficientFundsException(amount - Balance); } Balance -= amount; }}using Xunit;
public class AccountTest{ [Fact] public void CanPayWhatIsThere() { var account = new Account(100); Assert.True(account.CanPay(100)); Assert.False(account.CanPay(101)); }
[Fact] public void WithdrawTooMuch() { var account = new Account(100); var e = Assert.Throws<InsufficientFundsException>(() => account.Withdraw(130)); Assert.Equal("Не стигат 30 €", e.Message); Assert.Equal(100, account.Balance); }}ОК CanPayWhatIsThereОК WithdrawTooMuchТестове: 2, минали: 2, паднали: 0Assert.TrueиAssert.Falseпроверяват условие.Assert.Throws<вид>(код)пада, ако кодът не хвърли изключение от този вид. Видът е в< >, както вList<string>. Кодът се подава като ламбда:Assert.Throwsсам го пуска и хваща изключението. После го връща, за да провериш и съобщението (e.Message).
Вторият тест проверява и нещо, което лесно се забравя: след отказа балансът е същият.
Assert.Throws<T> иска изключение точно от вида T, не от негов наследник. InsufficientFundsException наследява Exception, но Assert.Throws<Exception> пада. За „този вид или негов наследник“ има Assert.ThrowsAny<T>:
public class AccountTest{ [Fact] public void ExactType() { var account = new Account(100); Assert.Throws<Exception>(() => account.Withdraw(130)); }
[Fact] public void TypeOrDerived() { var account = new Account(100); Assert.ThrowsAny<Exception>(() => account.Withdraw(130)); }}ОК TypeOrDerivedПАДА ExactType Assert.Throws() Failure: Exception type was not an exact match Expected: typeof(System.Exception) Actual: typeof(InsufficientFundsException)Тестове: 2, минали: 1, паднали: 1Идеята
Всеки тест започва на чисто
Тук двата теста делят една сметка от 100 €:
public class AccountTest{ private static Account account = new Account(100);
[Fact(DisplayName = "теглене на 40 € оставя 60 €")] public void Withdraw40() { account.Withdraw(40); Assert.Equal(60, account.Balance); }
[Fact(DisplayName = "теглене на 70 € оставя 30 €")] public void Withdraw70() { account.Withdraw(70); Assert.Equal(30, account.Balance); }}ОК теглене на 40 € оставя 60 €ПАДА теглене на 70 € оставя 30 € Не стигат 10 €Тестове: 2, минали: 1, паднали: 1account е static поле: една сметка за всички тестове. xUnit не обещава реда на тестовете и тук е пуснал първо теста за 40 €. На втория са останали 60 €, а той тегли 70 €. Ако редът беше обратният, щеше да падне другият. Тест, който зависи от друг, е капан. В xUnit няма отделен атрибут за подготовка: за всеки тест той прави нов обект от тестовия клас. Затова сметката е обикновено поле, а се създава в конструктора:
using Xunit;
public class AccountTest{ private readonly Account account;
public AccountTest() { account = new Account(100); }
[Fact(DisplayName = "теглене на 40 € оставя 60 €")] public void Withdraw40() { account.Withdraw(40); Assert.Equal(60, account.Balance); }
[Fact(DisplayName = "теглене на 70 € оставя 30 €")] public void Withdraw70() { account.Withdraw(70); Assert.Equal(30, account.Balance); }}ОК теглене на 40 € оставя 60 €ОК теглене на 70 € оставя 30 €Тестове: 2, минали: 2, паднали: 0DisplayName в [Fact] дава на теста име с думи. То се показва в резултата вместо името на метода.По-точно, за любопитните
конструктор преди всеки тест (нов обект за всеки тест)Dispose() от IDisposable след всеки тест (почистване)IClassFixture<T> един общ обект T за всички тестове в класа[Fact(Skip = "...")] тестът временно не се пуска[Trait("Category", "fast")] етикет, за да пуснеш само част от тестовете[MemberData(nameof(Prices))] редовете идват от static свойство или метод[ClassData(typeof(PriceRows))] редовете идват от отделен клас[Collection("База")] класовете в една колекция не вървят едновременно (xUnit пуска различните тестови класове паралелно)ITestOutputHelper печат от теста, който се вижда в резултатаИдеята
Една проверка, много случаи
Четири случая за отстъпката значат четири почти еднакви метода. [Theory] е тест с параметри, а всеки [InlineData] е един ред „цена, процент, очаквано“, с който xUnit пуска метода:
using Xunit;
public class DiscountTest{ [Theory] [InlineData(50, 10, 45.0)] [InlineData(99, 0, 99.0)] [InlineData(100, 10, 85.0)] [InlineData(200, 20, 150.0)] public void Apply(double price, int percent, double expected) { Assert.Equal(expected, Discount.Apply(price, percent)); }}ОК Apply(price: 100, percent: 10, expected: 85)ОК Apply(price: 200, percent: 20, expected: 150)ОК Apply(price: 50, percent: 10, expected: 45)ОК Apply(price: 99, percent: 0, expected: 99)Тестове: 4, минали: 4, паднали: 0Всеки ред е отделен тест с отделен резултат. xUnit прави името му от метода и стойностите на параметрите. Нов случай е нов ред, не нов метод. Важните случаи са на границите: точно 100 €, 99 €, 0%.
Пробвай
Намери грешката по теста
Доставката е 4 €, а от 50 € нагоре е безплатна. Тестът:
public class DeliveryTest{ [Theory] [InlineData(20, 4)] [InlineData(50, 0)] [InlineData(80, 0)] public void Price(int total, int expected) { Assert.Equal(expected, Delivery.Price(total)); }}ОК Price(total: 20, expected: 4)ОК Price(total: 80, expected: 0)ПАДА Price(total: 50, expected: 0) Assert.Equal() Failure: Values differ Expected: 0 Actual: 4Тестове: 3, минали: 2, паднали: 1На кой ред в кода е грешката?
Избери реда с грешката.
Падналият тест казва: при поръчка за точно 50 € доставката е 4 €, а трябва да е 0. Значи 50 не минава условието. total > 50 изключва самото 50. Трябва да е total >= 50. Тестът сочи случая, а кодът показва причината.
Идеята
Първо тестът
Касата трябва да приема промокод ESEN10 за 10%, без значение дали е с малки или главни букви. Този път пишеш тестовете първо:
public class PromoTest{ [Fact] public void KnownCode() { Assert.Equal(10, Promo.Percent("ESEN10")); }
[Fact] public void CodeIsCaseInsensitive() { Assert.Equal(10, Promo.Percent("esen10")); }
[Fact] public void UnknownCode() { Assert.Equal(0, Promo.Percent("NIKAKAV")); }}- Червено. Методът засега връща 0. Тестовете падат, и това е добре: значи наистина проверяват нещо.
class Promo{ public static int Percent(string code) { return 0; }}ОК UnknownCodeПАДА CodeIsCaseInsensitive Assert.Equal() Failure: Values differ Expected: 10 Actual: 0ПАДА KnownCode Assert.Equal() Failure: Values differ Expected: 10 Actual: 0Тестове: 3, минали: 1, паднали: 2- Зелено. Пишеш най-простия код, с който минават:
class Promo{ public static int Percent(string code) { if (code.ToUpper() == "ESEN10") { return 10; } return 0; }}ОК CodeIsCaseInsensitiveОК KnownCodeОК UnknownCodeТестове: 3, минали: 3, паднали: 0- Подреждане. Сега можеш да изнесеш кодовете в Dictionary, да добавиш нови и да подредиш метода. Тестовете ще кажат, ако счупиш нещо.
Това е TDD (test-driven development, разработка, водена от тестовете). Тестът първо те кара да решиш какво точно трябва да прави методът, преди да мислиш как.
По-точно
Групи тестове и фалшиви обекти
В xUnit няма сюит. Група тестове е етикет ([Trait]) на класовете, а пускането на групата е филтър. Тестовете на доставката с етикет „Каса“:
[Trait("Category", "Каса")]public class DeliveryTest{ [Theory] [InlineData(20, 4)] [InlineData(50, 0)] [InlineData(80, 0)] public void Price(int total, int expected) { Assert.Equal(expected, Delivery.Price(total)); }}Същия етикет имат и DiscountTest, и AccountTest. Командата пуска само тестовете с етикета. Изходът е от пускача на сайта, който филтрира по същия етикет (TestCaseFilter). VatTest от картата за ДДС е в същата програма, но няма етикет и не се пуска:
dotnet test --filter "Category=Каса"ОК ExtraFivePercentFrom100ОК Price(total: 20, expected: 4)ОК Price(total: 50, expected: 0)ОК Price(total: 80, expected: 0)ОК TenPercentOffОК теглене на 40 € оставя 60 €ОК теглене на 70 € оставя 30 €Тестове: 7, минали: 7, паднали: 0По-точно, за любопитните
- Понякога кодът зависи от нещо, което в тест не искаш да викаш: курс на валута от банка, плащане, имейл. Тогава подаваш фалшив обект (mock), който връща каквото му кажеш. Най-известната библиотека за това в .NET е Moq:
using Moq;using Xunit;
public interface IExchangeRates{ double EurTo(string currency);}
class Converter{ private readonly IExchangeRates rates;
public Converter(IExchangeRates rates) { this.rates = rates; }
public double ToUsd(double eur) { return eur * rates.EurTo("USD"); }}
public class PriceInUsdTest{ [Fact] public void ConvertsWithTodaysRate() { var rates = new Mock<IExchangeRates>(); rates.Setup(r => r.EurTo("USD")).Returns(1.10);
var converter = new Converter(rates.Object); Assert.Equal(11.0, converter.ToUsd(10), 0.001); rates.Verify(r => r.EurTo("USD"), Times.Once()); }}ОК ConvertsWithTodaysRateТестове: 1, минали: 1, паднали: 0new Mock<IExchangeRates>() прави обект, който изпълнява интерфейса, Setup(...).Returns(...) казва какво да връща, а Verify проверява, че методът е бил извикан точно веднъж (Times.Once()). rates.Object е самият фалшив обект, който подаваш на Converter. Интерфейсът е public, защото Moq сам прави клас, който го изпълнява. Тук на сайта тестът наистина се пуска.
- Още проверки освен Assert.Equal, Assert.True и Assert.Throws:
Assert.NotEqual(a, b) различни ли саAssert.Same(a, b) един и същ обект ли е, не просто равниAssert.NotSame(a, b)Assert.Null(x), Assert.NotNull(x)Assert.Equal(list1, list2) два списъка (или масива) с еднакви елементи в същия редAssert.Contains(x, items), Assert.DoesNotContain(x, items)Assert.Empty(items), Assert.Single(items)Assert.InRange(x, low, high) x е между low и highAssert.Matches(pattern, text) текстът отговаря на регулярен изразAssert.IsType<T>(x) обектът е точно от вида TAssert.ThrowsAny<T>(() => ...) изключение от вида T или негов наследникAssert.Fail("съобщение") тестът пада веднагаУпражнение
Напиши тестовете
Реши задачите в редактора си, с xUnit в проект за тестове (dotnet new xunit).
1. Калкулаторът на отстъпки. Напиши DiscountTest за Discount.Apply от първата карта. Покрий тези случаи: 80 € с 10% са 72 €, 100 € с 0% са 95 €, 150 € с 20% са 112,50 €, 0 € с 30% са 0 € и точно 100 € с 10% са 85 €. Пусни ги и срещу счупената версия с price > 100. Трябва да паднат точно двата случая с 100 €:
ОК Apply(price: 0, percent: 30, expected: 0)ОК Apply(price: 150, percent: 20, expected: 112.5)ОК Apply(price: 80, percent: 10, expected: 72)ПАДА Apply(price: 100, percent: 0, expected: 95) Assert.Equal() Failure: Values are not within tolerance 0.001 Expected: 95 Actual: 100ПАДА Exactly100GetsTheExtra Assert.Equal() Failure: Values are not within tolerance 0.001 Expected: 85 Actual: 90Тестове: 5, минали: 3, паднали: 22. Сметката. Напиши AccountTest за Account от картата „Да или не, и очаквано изключение“. Създавай сметката с 100 € в AccountTest(). Провери, че:
- теглене на 30 € оставя 70 €;
- може да се изтегли всичко и остава 0;
- теглене на 150 € хвърля
InsufficientFundsExceptionсъс съобщение „Не стигат 50 €“, а балансът остава 100 €; - 0 и -20 € хвърлят
ArgumentException(един [Theory] с [InlineData(0)] и [InlineData(-20)]).
3. Бъгът в средната оценка. Методът изглежда наред:
class Grades{ public static double Average(int[] grades) { int sum = 0; foreach (int g in grades) { sum += g; } return sum / grades.Length; }}ОК WholeAverageПАДА AverageWithAFraction Assert.Equal() Failure: Values are not within tolerance 0.001 Expected: 5.5 Actual: 5Тестове: 2, минали: 1, паднали: 1Намери причината и поправи метода, така че и двата да минат.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Хвърляне и свои изключения · Списъци · Dictionary и HashSet · Ламбди и делегати
- Продължава в: Нишки · Reflection и атрибути · Тестовете като предпазна мрежа · Refactoring на малки стъпки
- Задачи по темата: Програми в истинския свят