Testování podnikových aplikací
09.10.2021V tomto článku se budu věnovat problematice testování podnikových aplikací. Testování je klíčovou součástí vývoje softwaru. Základním pilířem je testovací pyramida, která nám říká, jak rozvrhnout a strukturovat testy.
Slovníček
Testovací pyramida |
Představuje doporučení, které je vhodné pro většinu aplikací.
|
In-process závislost | Závislost, která je součástí naší aplikace, například třída, NuGet balíček, knihovna apod. |
Out-of-process závislost |
Služba mimo aplikační kód, například databáze, SMTP server, message bus, platební brána apod. Je důležité rozpoznat, zda se jedná o:
|
Unit test | Testuje izolovanou část kódu, například doménovou (business) logiku, utility nebo jiné interní třídy. Jedná se o velmi rychlý automatizovaný test, který nevolá out-of-process závislost (buď ji nemá, nebo místo ní používá test double). V případě doménové logiky by test měl být srozumitelný i doménovému expertovi. Unit testy by měly být spustitelné paralelně. |
Integrační test | Testují se části kódu včetně in-process a out-of-process managed závislostí. Pro out-of-process unmanaged závislosti se používá test double. Testy je většinou potřeba spouštět sekvenčně, protože mohou využívat sdílené závislosti, například databázi. |
UI, E2E, UAT |
Testy určené k ověření aplikace jako celku. Testy se provádějí na již nasazené aplikaci v testovacím prostředí, které by mělo být co nejvíce podobné produkčnímu prostředí.
|
Test double | Obecný termín pro všechny testovací objekty, který původně vznikl jako analogie ke kaskadérovi (stunt double). Testovací objekty lze dále rozlišovat podle jejich účelu, například na Dummy, Fake, Stubs, Spies, Mocks. |
Obsah
Úvod
Než se pustím do popisu jednotlivých testů, pokusím se na začátku popsat, co si můžeme pod pojmem podniková aplikace představit, jak by mohlo vypadat složení vývojového týmu a jaké předpoklady by aplikace měla splňovat, abychom jí mohli efektivně testovat.
Podniková aplikace
Jedná se o aplikaci, která:
- Podporuje nebo automatizuje interní procesy vybrané organizace.
- Byla vyvinutá nebo přizpůsobená na míru.
- Je určená pro koncové uživatelé, resp. zaměstnance.
- Komplexními business pravidly.
- Dlouhou životností.
Složení týmu
Složení týmu se může lišit, a proto je vždy potřeba vzít v úvahu aktuální situaci a kontext. Například aplikaci může otestovat vývojový tým, ideálně pomocí automatizovaných testů, a následně proběhne dodatečná kontrola před tím, než se aplikace nasadí na produkci. Kontrolu mohou provést testeři, QA specialisté, produktový vlastník nebo vybraní lidé z byznysu, v závislosti na velikosti a důležitosti aplikace. Mezi výše uvedenými by měla probíhat úzká spolupráce. Většinu unit a integračních testů by měl provést vývojový tým.
Automatizované testy
Při testování se preferují automatizované testy, protože je můžeme spouštět opakovaně. Vývojáři mají i rychlejší zpětnou vazbu, zda vše funguje. Před releasem se doporučuje aplikaci zkontrolovat, protože testy nemohou postihnout vše, například vizuální prvky apod.
Testovatelnost kódu
Abychom mohli aplikaci efektivně testovat, je nezbytné ji správně navrhnout. To je úkol softwarového architekta nebo technického team leadera. Špatně navržená aplikace může vést k výrazně složitějšímu a časově náročnějšímu testování. Potřeba komplexních testů vede k anti-patternu převrácené pyramidy, která se vyznačuje vyšším podílem integračních a manuálních testů. Celkově špatný návrh zvýší náklady na testování, údržbu a v důsledku i k nižší kvalitě, která může vést k refactoringu nebo celkovému přepsání aplikace.
Pro zlepšení testovatelnosti bychom měli například:
- Vyhnout se tzv. anemickému modelu.
- Oddělit doménovou logiku (business layer) od zbytku aplikace.
- Vyhnout se tzv. Fat Controller anti-patternu. V případě webové aplikace je controller primárně zodpovědný pouze za příjem HTTP požadavku a vrácení požadavku, ale samotné zpracování může delegovat na dedikovanou službu.
- Dodržet DI (Dependency Injection) pattern, zejména pro out-of-process unmanaged závislosti.
Domain-driven design (DDD)
Tento přístup je velmi vhodný pro podnikové aplikace a poskytuje doporučení jak pro business analýzu, tak i pro technickou implementaci návrhu. Klade důraz na správné naprogramování business pravidel a vztahů s využitím principů OOP. O DDD jsem napsal samostatný článek.
Testovací pyramida
Doporučuji přečíst si například článek od Martina Fowlera, který princip testovací pyramidy vysvětluje. Základem je mít robustní pokrytí aplikace unit testy a vyhnout se tzv. Ice Cream Cone anti-patternu, který může u nových projektů vzniknout například kvůli špatnému návrhu aplikace nebo nevhodně nastaveným zodpovědnostem v týmu.
Struktura testu
Testy se většinou rozdělují do tří částí, pro které se používá zkratka AAA (arrange, act, assert). V případě integračního testu můžeme použít i čtvrtou, tzv. teardown sekci.
AAA
Arrange
Část, ve které si připravujeme testovací data. Pro in-process závislosti používáme vždy produkční třídy (nikoliv test double). Pokud by tato část byla komplikovaná, zabírala hodně řádek, nebo se duplikovala napříč testy, můžeme test zjednodušit pomocí návrhového vzoru Test Data Builder, Mother Object nebo použít tzv. Factory metodu.
Act
Část, ve které spouštíme kód, který testujeme. Objekt, který testujeme se nazývá SUT (system under test) a většinou se jedná o samostatnou řádku kódu.
Assert
V této části testujeme výsledek předchozí operace a můžeme použít libovolné množství tzv. assert metod.
Jmenná konvence
Jmenná konvence je obecně subjektivní záležitostí. Existuje několik doporučení, jak konvenci zformalizovat, například [MethodUnderTest]_[Scenario]_[ExpectedResult] nebo přístup Given-When-Then. Pravděpodobně však nejsrozumitelnější přístup (pro vývojáře, business analytika i doménového specialistu) je použití běžné věty. 🙂 Slova se v takovém případě oddělují podtržítkem. Například test s formalizovaným názvem IsDeliveryValid_InvalidDate_ReturnsFalse() bychom mohli napsat lépe jako Delivery_with_a_past_date_is_invalid().
Testování databáze
V případě integračních testů jsme často závislí na jedné nebo více databázích. Pokud se k databázi připojujeme napřímo a databáze je jednoduchá (neobsahuje procedury, triggery, funkce apod.), můžeme pro testy využít in-memory databázi, která integrační testy významně zrychlí. V opačném případě je nutné vytvořit testovací databázi, která odpovídá produkčnímu prostředí. S in-memory databází, pokud se dobře odladí, mám celkem dobré zkušenosti, ale většinou se doporučuje použít reálnou databázi, pokud je to možné. V obou případech je klíčové, aby databáze byla před spuštěním každého testu ve výchozím nebo kontrolovaném stavu (k tomu lze využít arrange nebo teardown část). Zároveň je důležité zajistit, aby všechny testy byly opakovaně spustitelné.
Příklady
Unit test
Tento test jsem vzal ze své utility knihovny, která usnadňuje kontrolu vstupních parametrů do metody a vyhazuje ArgumentException nebo ArgumentNullException výjimku. Třída CheckTests obsahuje vnořené třídy, pojmenované po metodách třídy Check, které testujeme, což pomáhá v přehlednosti. Testovací framework je xUnit.
using System;
using System.Collections.Generic;
using Xunit;
namespace Ninjanaut.Preconditions.Tests
{
public class CheckTests
{
public class NotNullOrEmptyOnCollection
{
[Fact]
public void Empty_argument_throws_exception_with_param_name_and_custom_message()
{
// Arrange
var value = new List<string>();
string paramName = nameof(value);
string message = "Foo";
// Act
void act() => Check.NotNullOrEmpty(value, paramName, message);
// Assert
var exception = Assert.Throws<ArgumentException>(paramName, act);
Assert.Equal(expected: paramName, actual: exception.ParamName);
Assert.Equal(expected: "Foo (Parameter 'value')", actual: exception.Message);
}
// ...
}
public class Require
{
[Fact]
public void True_condition_does_not_throw_exception()
{
// Arrange
bool condition = true;
// Act
void act() => Check.Require(condition);
// Assert
var exception = Record.Exception(act);
Assert.Null(exception);
}
// ...
}
// ...
}
}
Integration test
Test jsem vzal z aplikace, kterou jsem vytvářel pro účely evidence testování a očkování. Jednalo se o legislativní požadavek pro firmy v době covid pandemie. Test pošle jednoduchý požadavek, který simuluje odeslání webového formuláře a pak zkontroluje v in-memory databázi (ta je definovaná v CustomWebApplicationFactory třídě) jestli se požadavek správně uložil (tj. prošel správně controllerem a command handlerem).
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Web.Infrastructure.Database;
using Web.Models;
using Web.Models.Enums;
using Web.Tests.Data;
using Web.Tests.TestingTools;
using Xunit;
namespace Web.Tests.Controllers
{
public class HomeControllerTests : IClassFixture<CustomWebApplicationFactory<Startup>>
{
private readonly HttpClient _httpClient;
private readonly AppDbContext _context;
private readonly IServiceScope _scope;
public HomeControllerTests(CustomWebApplicationFactory<Startup> factory)
{
_httpClient = factory.CreateDefaultClient();
_scope = factory.Server.Host.Services.CreateScope();
_context = _scope.ServiceProvider.GetRequiredService<AppDbContext>();
_context.Database.EnsureCreated();
var seeder = new DatabaseSeeder(_context);
seeder.SeedDefaults();
}
[Fact]
public async Task Post_Record_Without_Photo()
{
// Arrange
var record = new Record(
name: "John",
lastName: "Doe",
employeeNumber: 12345678,
testResultId: (int)TestResultEnum.VaccinatedSingleShot,
dateOfTest: DateTime.Now,
note: "John Doe is vaccinated with Johnson & Johnson single-shot COVID-19 vaccine!",
photo: null,
user: "JohnDoe");
// Prepare post data
var formContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("Command.Name", record.Name),
new KeyValuePair<string, string>("Command.LastName", record.LastName),
new KeyValuePair<string, string>("Command.EmployeeNumber", record.EmployeeNumber.ToString()),
new KeyValuePair<string, string>("Command.TestResultId", record.TestResultId.ToString()),
new KeyValuePair<string, string>("Command.DateOfTest", record.DateOfTest.ToString()),
new KeyValuePair<string, string>("Command.Note", record.Note),
});
// Act
var response = await _httpClient.PostAsync("home/index", formContent);
// Assert
var savedRecord =
_context.Records
.Where(x => x.EmployeeNumber == record.EmployeeNumber)
.FirstOrDefault();
Assert.NotNull(savedRecord);
Assert.Equal(record.Name, savedRecord.Name);
Assert.Equal(record.LastName, savedRecord.LastName);
Assert.Equal(record.EmployeeNumber, savedRecord.EmployeeNumber);
Assert.Equal(record.TestResultId, savedRecord.TestResultId);
Assert.Equal(record.DateOfTest.Date, savedRecord.DateOfTest.Date);
Assert.Equal(record.Note, savedRecord.Note);
}
}
}
E2E test
End to end test je varianta integračního testu, která testuje již nasazenou aplikaci a ověřuje správnost pomocí UI. V tomto příkladě test vyplní a odešle registrační formulář a kontroluje jestli stránka zobrazí odpověď "Registration was successful". Na pozadí se provádí databázové dotazy a kontaktuje se SMTP server, ale to již nekontrolujeme, pouze nám stačí vědět, že nedošlo k chybě. Test je napsaný s použitím JS testovacího frameworku Jest od Facebooku a Puppeteer knihovny od Googlu, která umí ovládat chrome a spouštět ho v tzv. HEADLESS módu, kdy se prohlížeč spustí na pozadí. Jedná se zároveň o moderní alternativu k populárnímu Selenium WebDriveru. Puppeteer se mimo jiné dá použít i jako crawler (web scraper), protože umí spustit klientský javascript. 🙂 Pomocí Jestu a Puppeteer knihovny můžeme psát celkem pěkné E2E testy (alternativně můžeme použít i třeba cypress.io nebo Playwright od Microsoftu, který je napsaný pro JS, ale i Javu, Python a .NET). Test pak může vypadat následovně.
const timeout = process.env.HEADLESS ? 5 * 60 * 1000 /* 5min*/ : 1 * 60 * 1000 /* 1min*/;
// can be omitted if it's defined as global variable in jest.config.js
const baseURL = 'https://test.application.example'
beforeAll(async () => {
await page.goto(baseURL, {waitUntil: 'networkidle0'});
}, timeout);
describe('Registration', () => {
test('Submit form with valid data', async () => {
// Arrange
await page.click('[href="/Identity/Account/Register"]');
await page.waitForSelector('form');
let username = (new Date()).getTime().toString();
let email = `${username}@test.application.example`;
let password = 'Pa$$w0rd';
let phone = '+420123456789'
await page.type('#Input_Email', email);
await page.type('#Input_Password', password);
await page.type('#Input_ConfirmPassword', password);
await page.type('#Input_PhoneNumber', phone);
// Act
await page.click('[type="submit"]');
// Assert
await page.waitForSelector('body > div.container.body-content > p');
const html = await page.$eval('body > div.container.body-content > p', el => el.innerHTML);
expect(html).toContain('Registration was successful');
}, timeout);
});
Závěr
Na závěr jsem si vypůjčil dva obrázky z knížky Unit Testing. První nám shrnuje rozdíl mezí základní klasifikací testů (unit, integration, E2E) a druhý nám říká, že testování je investice, která se nám vrací v čase.