Testování podnikových aplikací

09.10.2021

V tomto článku se pokusím popsat problematiku testování podnikových neboli enterprise aplikací. Jedná se o široké téma a existuje mnoho nástrojů, technik a přístupů, které můžeme použít. Článek bude psaný spíše optikou pro vývojáře a proto bude občas obsahovat implementační detaily. Terminologie a klasifikace testů není stoprocentně ustálená, takže se může ve Vaší společnosti lišit. Jelikož se testování jako obor neustále vyvíjí tak je dobré mít na paměti, že co bylo aktuální před rokem nemusí být aktuální dnes 🙂 Určitě budu moc rád, pokud se podělíte v komentářích i o svoje zkušenosti nebo připomínky.

Slovníček

Testovací pyramida Testovací pyramida představuje kompromis, který je vhodný pro většinu aplikací. Říká nám, že nejvíce by mělo být unit testů, které jsou rychlé, jednoduché na vytvoření a můžeme s nimi pokrýt okrajové scénáře. Integračních testů by zase mělo být méně, protože jsou pomalejší a náročnější na správu, ale můžeme díky nim otestovat správnost fungování větší části systému (integrační test) nebo jako celku (integrační E2E test).
In-process závislost Závislost, která je součástí naší aplikace, například nějaká třída, nuget balíček, knihovna apod.
Out-of-process závislost Služba mimo naší aplikaci, například databáze, SMTP server, message bus, platební brána apod. Je důležité rozpoznat, jestli 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 Unit neboli jednotkový test je kód, který primárně testuje doménovou logiku, ale může testovat i utility nebo jiné interní třídy. Rychlý, izolovaný test. Doménový test by měl být zároveň srozumitelný i doménovému expertovi, nikoli pouze programátorovi. Unit testy je možné spouštět paralelně.
Integrační test Jakýkoliv test, který není unit test. Integrační testy můžeme dále klasifikovat na E2E (UI), API, UAT, system, component testy apod., ale základem je rozdělení pouze na integrační a E2E test. Ostatní pojmy nám specifikují účel testu, ale z programátorského pohledu jsou obdobné. Většinou (s výjimkou E2E) se jedná o test, který testuje i controller, všechny in-process závislosti, out-of-process managed závislosti a mockuje out-of-process unmanaged závislosti. Integrační testy je většinou potřeba spouštět sekvenčně, jelikož mohou používat sdílené závislosti, jako například databázi.
E2E testy E2E (end-to-end) integrační test běží na již nasazené (deployované) aplikaci v testovacím prostředí, které by mělo být co nejvíce podobné produkčnímu prostředí. Oproti integračnímu testu se liší i v tom, že nemockuje out-of-process unmanaged závislosti (například SMTP emailing, platební bránu apod.). Jestli test proběhl správně se ověřuje pouze z pohledu uživatele, někdy se těmto testům tedy také říká UI (user interface) testy. E2E testy mohou být považované za systémové testy nebo pokud jsou součástí QA (quality assurance) fáze, tak za UAT, ale konkrétní definici je vždy potřeba ověřit u team leadera nebo test manažera, pokud se tyto pojmy ve Vaší společnosti používají.
Test double Obecný termín pro všechny testovací objekty. Původně vznikl jako analogie ke kaskadérovi (stunt double). Takové objekty je možné dále dělit na Dummy, Fake, Stubs, Spies, Mocks objekty apod. Rozdíly jsou ale tak malé, že v praxi pravděpodobně stačí rozlišovat pouze mocks a stubs.
Mock Objekt, který reprezentuje závislost, kterou testovací kód volá, aby odeslal data nebo změnil nějaký stav, například volání SMTP serveru nebo message bus (outcoming interaction).
Stub Objekt, který reprezentuje závislost, kterou testovací kód volá, aby získal nějaká data, například z databáze (incoming interaction). Pokud objekt odesílá a přijímá data zároveň, jedná se o Mock.
CQS Stub narozdíl od mocku neobsahuje side efekty a díky tomu je případně možné mocky a stuby mapovat na command/query operace (CQS). Side efekt znamená jakoukoliv změnu stavu, ať už je to změna nějaké proměnné (property/field) volaného objektu, parametru, který byl zadaný jako reference (objekt), zapsání na disk, do databáze nebo odeslání emailu. Operace se side efektem se nazývá command a měla by ideálně vracet void (v praxi může vracet třeba i ID vytvořeného objektu). Pokud operace vrací něco jiného než void, jedná se o query.

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

Podporuje nebo do určité míry automatizuje konkrétní interní procesy, jedná se o téměř libovolný interní systém. Většinou se vyznačuje následujícím

  • Komplikovaná business pravidla
  • Delší životnost
  • Střední množství dat
  • Nížší nároky na výkon
Z výše uvedeného vyplývá, že největší pozornost bychom měli věnovat business logice a způsobu jakým budeme aplikaci dlouhodobě spravovat. Optimalizaci výkonu lze ponechat na později, pokud bude potřeba.

Složení týmu

Poskládat tým se dá různě a proto je vždy potřeba vzít v úvahu aktuální situaci. Například aplikace od vývojového týmu může jít už otestovaná, 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 specialisti, produktový vlastník nebo vybraní lidé z businessu. Čím menší silo (respektive čím užší spolupráce) bude mezi vývojáři a testery, tím lépe. Většinu testů (především pak unit testů a integračních non-UI testů) by ale měl provést vývojový tým, nikoliv testeři (v případě, že nejsou součástí vývojového týmu a spíše ověřují zda systém funguje).

Ruční a automatizované testy

Při testování preferujeme automatizované testy, protože je můžeme spouštět opakovaně a jsou velmi rychlé. Díky tomu mají vývojáři rychlou zpětnou vazbu a ví jestli náhodou něco nerozbili 🙂 Automatizované testy také snižují obavy ze změny kódu, což je velmi důležité. Né vše jde ale automatizovat a někdy napsat takový test bývá velmi pracné a komplikované, proto se pak musí funkcionalita zkoušet ručně a to po každé změně v aplikaci. Před releasem pak doporučuji aplikaci zkontrolovat, protože testy nemohou postihnout vše, například vizuální prvky apod.

Testovatelnost kódu

Abychom mohli aplikaci rozumně testovat, musíme jí správně navrhnout. To je úkol softwarového architekta nebo team leadera. Pokud se aplikace špatně navrhne, tak testování bude složité (více než je nutné) a také může dojít k převrácení testovací pyramidy (Ice Cream Cone anti-pattern), což by v takovém případě mělo za následek vyšší míru ručních a integračních testů.

Pro zlepšení testovatelnosti bychom se měli například vyvarovat tzv. anemickému modelu a oddělit doménovou logiku od zbytku aplikace. Můžeme se vyvarovat i tzv. fat controller anti-patternu. Controller (v případě webové aplikace) je zodpovědný za příjem HTTP požadavku a odeslání odpovědi, samotné zpracování operace ale může být delegováno (především v případě, kdy se nejedná o triviální operaci) například command handleru nebo nějaké aplikační službě, kterou controller zavolá. Out-of-process unmanaged závislosti by pak měly být předávány pomoci DI patternu, abychom pro ně mohli vytvořit test double.

Domain driven design

Jedná se o přístup, který je pro podnikové aplikace velmi vhodný a obsahuje doporučení jak pro business analýzu tak i pro technickou implementaci návrhu. Do popředí se zde staví důraz na správné naprogramování business pravidel a vztahů pomocí OOP. O tomto tématu jsem napsal samostatný článek.

Testovací pyramida

Určitě doporučuji článek od Martina Fowlera, který pyramidu dobře popisuje. Pyramida nám v podsatě říká, že bychom měli mít nejvíce unit testů a nejméně E2E (UI) testů. Měli bychom se vyvarovat obrácené pyramidě, tzv. Ice cream cone anti patternu, který u nových projektů může vzniknout například špatným návrhem aplikace nebo nešťastně nastavenými zodpovědnostmi 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 doubles). Pokud by tato část byla komplikovaná, zabírala hodně řádek, nebo se duplikovala napříč testy, tak 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 Given-When-Then přístup, ale pravděpodobně nejsrozumitelnější (jak pro vývojáře, business analytika nebo doménového specialistu) je použití normální věty 😁 Mezery mezi slovy se pak v takovém případě oddělují podtržítkem. Například test s formalizovaným pojmenováním IsDeliveryValid_InvalidDate_ReturnsFalse() bychom mohli lépe napsat jako Delivery_with_a_past_date_is_invalid().

Testování databáze

V případě integračních testů částo máme závislost na jednu nebo více databází. Pokud se k databázi připojujeme napřímo a databáze je jednoduchá ve smyslu, že neobsahuje procedury, triggery, funkce apod. tak můžeme pro test použít in-memory databázi, která nám integrační testy významně zrychlí. V opačném případě musíme vytvořit testovací databázi, která je stejná jako produkční. V obou případech bychom před spuštěním každého testu měli mít databázi v nějakém výchozím nebo kontrolovaném stavu (k tomu můžeme použít arrange nebo teardown část) a je důležité, aby veškeré testy šly spouštět opakovaně.

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.

Protože jsou testy velmi důležitá součást aplikace, doporučuji k nim přistupovat stejně jako by se jednalo o produkční kód. U testů není ani tak důležitá kvantita a code coverage, ale předeším kvalita a hlavně, pokud je máme napsané, tak je musíme také spouštět 😁

Odkazy