Testování podnikových aplikací

09.10.2021

V 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í.
  • Největší podíl by měly tvořit jednotkové testy (unit testy), které jsou jednoduché na vytvoření, automatizované, rychlé a vhodné pro pokrytí i méně obvyklých scénářů.
  • Následují integrační testy, které testují větší systémové celky. Obvykle bývají pomalejší a náročnější na vytvoření a správu než jednotkové testy. Pokud testujeme více systémů, můžeme hovořit také o systémových integračních testech (SIT).
  • Do poslední kategorie testů spadají UI, E2E a UAT testy, které slouží k otestování aplikace jako celku. Tuto kategorii lze technicky považovat za součást integračního testování, protože tyto testy také ověřují integraci mezi různými systémy a částmi aplikace. Nicméně každý z těchto typů testů má své specifické zaměření, které je od integračních testů odlišuje.
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:
  • Managed závislost, kterou máme plně pod kontrolou, například aplikační databáze.
  • Unmanaged závislost, kterou spravuje někdo jiný, například SMTP server. Většinou bývá sdílená.
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í.
  • UI (User Interface): Testy zaměřené na uživatelské rozhraní aplikace.
  • E2E (End-to-End): Tento test může být rozdělen na horizontální a vertikální. Horizontální test ověřuje celý uživatelský scénář, zatímco vertikální se zaměřuje na průchod všemi částmi systému.
  • UAT (User Acceptance Testing): Uživatelé ověřují, zda aplikace splňuje jejich požadavky a je připravena k nasazení do produkčního prostředí.
Obecně se při těchto testech nepoužívají test doubles pro out-of-process unmanaged závislosti, jako je například e-mailová služba.
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.
Obvykle se vyznačuje:
  • Komplexními business pravidly.
  • Dlouhou životností.
Z výše uvedeného vyplývá, že bychom měli věnovat pozornost business logice a způsobu, jakým budeme aplikaci dlouhodobě spravovat. V případě vyššího počtu uživatelů, většího množství dat nebo náročných operací musíme počítat i s optimalizací výkonu.

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);
});
výsledek testu pak uvidíme přímo v konzoli

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.

Testy jsou velmi důležitou součástí aplikace a proto bychom k nim měli přistupovat jako k produkčnímu kódu. 🙂

Odkazy