Testování podnikových aplikací

09.10.2021

V tomto článku se pokusím částečně popsat problematiku testování podnikových aplikací. Terminologie není plně ustálená a je potřeba se na ní vždy domluvit. Testování je klíčová součást sw vývoje a základním pilířem je testovací pyramida, která nám pomáhá uvědomit si na jaké testy bychom se měli zaměřit.

Slovníček

Testovací pyramida Představuje doporučení, které je vhodné pro většinu aplikací. Nejvíce by mělo být unit testů, které jsou jednoduché na vytvoření, rychlé a můžeme s nimi pokrýt okrajové scénáře. Potom máme k dispozici integrační testy, které nám umožňují otestovat větší část systému, ale jsou náročnější na vytvoření, správu a jsou pomalejší. Do kategorie integračních testů také spadají UI testy (někdy nazývané jako systémové nebo E2E) a můžeme pomocí nich testovat aplikaci jako celek.
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 aplikační kód, 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 Testuje izolovanou část kódu, například doménovou (business) logiku, utility nebo jiné interní třídy. Jedná se o velmi rychlý test, který nevolá out-of-process závislost (buď jí nemá nebo místo ní volá test double). V případě doménové logiky by test měl být srozumitelný i doménovému expertovi. Unit testy by mělo být možné spouštět paralelně.
Integrační test Testuje část kódu včetně out-of-process závislostí. Integrační testy je možné rozdělit na testy, které

  • Testují část sytému - integrační test nebo service test. Většinou testují controller, všechny in-process závislosti, out-of-process managed závislosti a mockují out-of-process unmanaged závislosti.
  • Testují aplikaci jako celek - UI, E2E nebo systémové testy. Spouštějí se na nasazenou aplikaci a nemusí mockovat out-of-process unmanaged závislosti (například SMTP emailing).
Testy je většinou potřeba spouštět sekvenčně, protože mohou používat sdílené závislosti, například databázi.
UI, E2E (end-to-end), systémové testy Testují aplikaci jako celek. Testy se spouští na již nasazenou aplikaci v testovacím prostředí, které by mělo být co nejvíce podobné produkčnímu prostředí. Často se testy ověřují pouze z pohledu uživatele.
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.

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