Към съдържанието
wannacode

Тестове с xUnit

Накратко: Unit тестът е код, който проверява едно малко парче, обикновено един метод, при всяка промяна; в C# се пише с xUnit, [Fact] и Assert.Equal.

Проблем

Отстъпката, която се счупи тихо

Правилото на магазина е: отстъпката е в проценти, а при покупка от 100 € нагоре се добавят още 5%. Пишеш метода и го пробваш на ръка:

Program.cs
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));
Изход
45
170

50 € с 10% са 45 €, а 200 € с 15% са 170 €. Изглежда наред и го пускаш. След седмица клиент с покупка за точно 100 € плаща 90 € вместо 85 €. Условието е price > 100, а трябва да е price >= 100.

Никой не е пробвал точно 100 €. А при всяка следваща промяна в метода трябва да пробваш пак всички случаи на ръка.

Идеята

Код, който проверява код

Вместо да гледаш изхода с очите си, пишеш малки методи, които викат твоя код и сравняват резултата с очаквания. Това е тест. Пускаш всички тестове с едно натискане, след всяка промяна, и те казват кое се е счупило.

Вид Какво проверява Пример
Unit тест едно малко парче, обикновено един метод, отделно от останалото Discount.Apply(100, 10) връща 85
Интеграционен как парчетата работят заедно: с база данни, файл, друга услуга поръчката се записва в базата и се чете обратно
End-to-end цялото приложение, както го ползва човек клиентът слага в количката, плаща и получава имейл
В този урок пишем unit тестове. В C# за тях най-често се ползва библиотеката xUnit.

Защо си струва:

  • грешката се вижда веднага, докато още помниш какво си променил, а не когато клиентът се оплаче;
  • тестът показва как се ползва кодът: кой случай какво връща;
  • можеш да подреждаш кода смело. Ако тестовете са зелени и след промяната, поведението не се е променило.
По-точно, за любопитните

Има и други видове тестове: функционални (отговаря ли програмата на изискванията), за производителност (колко заявки издържа), за сигурност (има ли пробиви) и на потребителския интерфейс (бутоните, формите). Всички те стъпват на unit тестовете: те са най-много, най-бързи и най-евтини за поддържане.

Освен xUnit в .NET ще срещнеш NUnit ([Test], [SetUp]) и MSTest ([TestMethod], [TestInitialize]). Идеите са същите. Тук ползваме xUnit: с него е и шаблонът dotnet new xunit.

Виж го

Първият тест

Тестовете за Discount са в отделен клас DiscountTest, в отделен проект за тестове:
DiscountTest.cs
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));
}
}
Стъпка по стъпка, с думи
  1. DiscountTest има два теста. xUnit пуска всеки поотделно и пали лампичка до него.
  2. Тест 1, подготовка: цена 50 € и 10% отстъпка.
  3. Действие: Discount.Apply(50, 10) връща 45.
  4. Проверка: Assert.Equal сравнява очакваното 45 с върнатото 45. Еднакви са, тестът минава.
  5. Тест 2: точно 100 € и 10%. Очакваме 85, защото от 100 € нагоре има още 5%.
  6. Условието в метода е price > 100, а 100 не е по-голямо от 100. Методът връща 90.
  7. Очаквано 85, върнато 90: тестът пада и казва точно това.
  8. Поправяш условието на price >= 100 и пускаш тестовете пак.
  9. Сега 100 минава условието и методът връща 85.
  10. Очаквано и върнато съвпадат. И двата теста са зелени.
По-точно, за любопитните
  • В истински проект тестовете са в отделен проект до кода, например Shop.Tests до Shop. Visual Studio и Rider показват всички тестове в Test Explorer: натискаш „Run“ и виждаш резултата.
Структура
Shop/Discount.cs кодът
Shop/Shop.csproj
Shop.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 добавя връзката към кода:
Shop.Tests.csproj
<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));
}
}
Отговор

Идеята

Да или не, и очаквано изключение

Освен равенство често проверяваш условие или че кодът отказва. Ето Account от модула за изключенията, с нов метод CanPay:

Account.cs
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;
}
}
AccountTest.cs
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, паднали: 0
  • Assert.True и Assert.False проверяват условие.
  • Assert.Throws<вид>(код) пада, ако кодът не хвърли изключение от този вид. Видът е в < >, както в List<string>. Кодът се подава като ламбда: Assert.Throws сам го пуска и хваща изключението. После го връща, за да провериш и съобщението (e.Message).

Вторият тест проверява и нещо, което лесно се забравя: след отказа балансът е същият.

Assert.Throws<T> иска изключение точно от вида T, не от негов наследник. InsufficientFundsException наследява Exception, но Assert.Throws<Exception> пада. За „този вид или негов наследник“ има Assert.ThrowsAny<T>:

AccountTest.cs
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 €:

AccountTest.cs
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, паднали: 1

account е static поле: една сметка за всички тестове. xUnit не обещава реда на тестовете и тук е пуснал първо теста за 40 €. На втория са останали 60 €, а той тегли 70 €. Ако редът беше обратният, щеше да падне другият. Тест, който зависи от друг, е капан. В xUnit няма отделен атрибут за подготовка: за всеки тест той прави нов обект от тестовия клас. Затова сметката е обикновено поле, а се създава в конструктора:

AccountTest.cs
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, паднали: 0
Конструкторът се вика преди всеки тест, защото всеки тест получава нов обект. DisplayName в [Fact] дава на теста име с думи. То се показва в резултата вместо името на метода.
По-точно, за любопитните
Още неща от xUnit, които ще срещнеш:
xUnit
конструктор преди всеки тест (нов обект за всеки тест)
Dispose() от IDisposable след всеки тест (почистване)
IClassFixture<T> един общ обект T за всички тестове в класа
[Fact(Skip = "...")] тестът временно не се пуска
[Trait("Category", "fast")] етикет, за да пуснеш само част от тестовете
[MemberData(nameof(Prices))] редовете идват от static свойство или метод
[ClassData(typeof(PriceRows))] редовете идват от отделен клас
[Collection("База")] класовете в една колекция не вървят едновременно
(xUnit пуска различните тестови класове паралелно)
ITestOutputHelper печат от теста, който се вижда в резултата

Идеята

Една проверка, много случаи

Четири случая за отстъпката значат четири почти еднакви метода. [Theory] е тест с параметри, а всеки [InlineData] е един ред „цена, процент, очаквано“, с който xUnit пуска метода:

DiscountTest.cs
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 € нагоре е безплатна. Тестът:

DeliveryTest.cs
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

На кой ред в кода е грешката?

Избери реда с грешката.

Идеята

Първо тестът

Касата трябва да приема промокод ESEN10 за 10%, без значение дали е с малки или главни букви. Този път пишеш тестовете първо:

PromoTest.cs
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"));
}
}
  1. Червено. Методът засега връща 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
  1. Зелено. Пишеш най-простия код, с който минават:
class Promo
{
public static int Percent(string code)
{
if (code.ToUpper() == "ESEN10")
{
return 10;
}
return 0;
}
}
Изход
ОК CodeIsCaseInsensitive
ОК KnownCode
ОК UnknownCode
Тестове: 3, минали: 3, паднали: 0
  1. Подреждане. Сега можеш да изнесеш кодовете в Dictionary, да добавиш нови и да подредиш метода. Тестовете ще кажат, ако счупиш нещо.

Това е TDD (test-driven development, разработка, водена от тестовете). Тестът първо те кара да решиш какво точно трябва да прави методът, преди да мислиш как.

По-точно

Групи тестове и фалшиви обекти

В xUnit няма сюит. Група тестове е етикет ([Trait]) на класовете, а пускането на групата е филтър. Тестовете на доставката с етикет „Каса“:

DeliveryTest.cs
[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:
PriceInUsdTest.cs
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, паднали: 0

new 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 и high
Assert.Matches(pattern, text) текстът отговаря на регулярен израз
Assert.IsType<T>(x) обектът е точно от вида T
Assert.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, паднали: 2

2. Сметката. Напиши AccountTest за Account от картата „Да или не, и очаквано изключение“. Създавай сметката с 100 € в AccountTest(). Провери, че:

  • теглене на 30 € оставя 70 €;
  • може да се изтегли всичко и остава 0;
  • теглене на 150 € хвърля InsufficientFundsException със съобщение „Не стигат 50 €“, а балансът остава 100 €;
  • 0 и -20 € хвърлят ArgumentException (един [Theory] с [InlineData(0)] и [InlineData(-20)]).

3. Бъгът в средната оценка. Методът изглежда наред:

Grades.cs
class Grades
{
public static double Average(int[] grades)
{
int sum = 0;
foreach (int g in grades)
{
sum += g;
}
return sum / grades.Length;
}
}
Напиши два теста: средното на 4 и 6 е 5,0, а средното на 5 и 6 е 5,5 (с допустима разлика 0,001). Единият пада:
Резултат
ОК WholeAverage
ПАДА AverageWithAFraction
Assert.Equal() Failure: Values are not within tolerance 0.001
Expected: 5.5
Actual: 5
Тестове: 2, минали: 1, паднали: 1

Намери причината и поправи метода, така че и двата да минат.

Тест

Провери се

1. Какво проверява един unit тест?
2. Тестът пада с „Expected: 85“ и „Actual: 90“. Колко върна методът?
3. Защо сметката се създава в конструктора на тестовия клас, а не веднъж в static поле?
4. Как проверяваш, че Withdraw(130) хвърля InsufficientFundsException?
5. Кой е редът при TDD?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·