Hlavní navigace

Testování webových aplikací – některé možnosti JUnit5

Pavel Herout

Tento článek je třetím a posledním článkem z minisérie týkající se funkcionálního testování webových aplikací. Navazuje na oba předchozí články. Ukážeme si, jaké možnosti má testovací framework JUnit5.

Doba čtení: 13 minut

Sdílet

Jako testovací framework jsem využíval JUnit5. Ten byl publikován ve verzi 5.0.0 v červenci 2016. Protože v té době byl úspěšně používán jeho předchůdce JUnit4, byl nový JUnit5 dlouhou dobu na okraji zájmu. V roce 2018 se však zřejmě začal používat masivněji, což lze odhadovat podle vzniku dalších subverzí. V tomto roce byly vydány subverze 5.1, 5.2 a 5.3. Subverze 5.4 (kterou jsem používal) byla vydána v únoru 2019. V současné době (září 2019) je stabilní verze 5.5.2.

Osobní poznámka: Pro předchozí verzi testů jsem používal JUnit4, protože se držím osvědčené zásady: „Nepoužívej verzi x.0, počkej si minimálně na verzi x.1.“ Tato zásada se mi opět osvědčila. Ve verzi 5.4 (na rozdíl od verze 5.0) jsou již možnosti, které jsem potřeboval a znal z JUnit4 (např. TestWatcher). Ale samozřejmě, JUnit5 je koncepčně lepší než JUnit4. V současné době používám JUnit5 bez výjimky.

Nejdůležitější vychytávky JUnit5

Framework JUnit5 má ohromné množství možností, které není možné sepsat do jednoho článku. Když jej studentům vykládám, zabere výklad nejzajímavějších věcí dvě tříhodinové přednášky (tedy včetně principů jednotkového testování). Proto se zde dále omezím jen na některé z mého pohledu důležité věci, které jsem v projektu použil a oceňuji je.

1. Pojmenování testů

Toto by se zdálo být jako naprostá trivialita a samozřejmost, ale z mého pohledu není. Každý test i celá testovací třída může být pomocí anotace @DisplayName významově pojmenován. Samozřejmě s použitím většiny dostupných znaků, čili není třeba se omezovat stylem identifikátorů v Javě.

Maličkost, ale pro přehlednost velkého počtu výsledků k nezaplacení.

Příklad:

@DisplayName("Student My Exam Dates Tests")
public class Stu_MyExamDates_Test extends ActiveTestSupport {
  @Test
  @DisplayName("Several exam dates")
  void test_1() { ...

2. Štítky testů

Další maličkost k nezaplacení. Štítky (tags) známé z mnoha jiných oblastí lze pomocí anotace @Tag použít jak pro třídu testů, tak i pro jednotlivé testy.

Při externím spouštění testů (viz dále) lze zahrnovat (include) nebo vylučovat (exclude) příslušně označené testy.

Poznámka:
Eclipse to údajně to umožňuje rovnou ze svého IDE. U IntelliJ IDEA jsem tuto možnost nenašel.

Většinou mi stačilo použít štítky pro třídu testů, v omezených případech jsem používal štítky i pro jednotlivé testy:

@DisplayName("Student My Exam Dates Tests")
@Tag(ACTIVE)
@Tag(ONE_ACTIVITY)
public class Stu_MyExamDates_Test extends ActiveTestSupport {
  @Test
  @DisplayName("Several exam dates")
  void test_1() {
    setParameters("cyan", "strict", "Programming in Java");
    Stu_MyExamDates_Actions.unregisterExamDate(student, subject);
    checkWholeContent();
  }

  @Test
  @Tag(BOUNDARY)
  @DisplayName("One exam date")
  void test_2() {
    setParameters("brown", "strict", "Programming in Java");
    Stu_MyExamDates_Actions.unregisterExamDate(student, subject);
    checkWholeContent();
  }

}

3. Nastavení na samém počátku a samém konci všech testů

Toto je při testování webové aplikace kruciální záležitost. Na samém počátku je nutno jednou a pouze jednou načíst konfiguraci a inicializovat příslušný driver webového prohlížeče. A na konci zapsat závěrečnou statistiku do logovacího souboru a ukončit driver, tj. zavřít prohlížeč.

To jsou časově náročné akce, a pokud by měly probíhat opakovaně před spuštěním každé třídy testů, pak by se významně (násobně) prodloužil čas běhu testů.

JUnit5 k tomu dává mechanismus callback společně s extension modelem. Obojí je v manuálu pro JUnit5 nějakým způsobem popsáno, ovšem to, jak zařídit, aby akce proběhla opravdu pouze jednou nezávisle na počtu spuštěných testovacích tříd, tam není.

Naštěstí autoři JUnit5 odpovídají na dotazy i na StackOverflow, takže řešení je v mém případě následující (kráceno):

public class TestSetting implements BeforeAllCallback,
                                    BeforeTestExecutionCallback,
                                    ExtensionContext.Store.CloseableResource {

  /** temporally variable assuring, that initialization will run just once only */
  private static boolean started = false;

  /**
   * One-time initialization of web driver
   * Possible several others initializations, eg. Logger etc.
   *
   * the string "Hello" is an unique string and it is never used anywhere
   * in whole project
   *
   * @param context parameter supplied by Selenium
   */
  @Override
  public void beforeAll(ExtensionContext context) {
    if (started == false) {
      started = true;
      context.getRoot().getStore(GLOBAL).put("Hello", this);
      Configurations.getInstance().setConfiguration();
      ProjectLogger.logStartInfo();
    }
  }

  /**
   * Termination of web driver after very last test
   * Possibly several others final activities, eg. Logger etc.
   */
  @Override
  public void close() {
    Drivers.quitWebDriver();
    ProjectLogger.logEndInfo();
    ScreenShot.deleteEmptyScreenshotDirectory();
  }
}

Tato třída se v každém z testů aktivuje díky mechanismu extension, který je prakticky realizován anotací  @ExtendWith.

Příklad použití:

@ExtendWith(TestSetting.class)
@DisplayName("Student My Exam Dates Tests")
@Tag(ACTIVE)
@Tag(ONE_ACTIVITY)
public class Stu_MyExamDates_Test extends ActiveTestSupport {
  @Test
  @DisplayName("Several exam dates")

4. Vyhodnocení výsledku jednotlivých testů

Pokud výsledky logujeme, chceme mít možnost je logovat po dokončení (úspěšném i neúspěšném) každého testu. V JUnit4 tato možnost byla. V JUnit5 byla až do verze 5.3 včetně jen v případě externího spouštění testů (ne z vývojového prostředí). Od verze 5.4 je ale již k dispozici kýžené rozhraní TestWatcher s default  metodami.

Pokud tedy bude třída implementovat toto rozhraní, je možnost reagovat na různá ukončení testů.

public class TestEvaluation implements TestWatcher {
  /** number of passed test */
  private static int testPassed = 0;

  /** number of all test's failures */
  private static int testFailed = 0;

  /**
   * Activity, when test fails
   */
  @Override
  public void testFailed(ExtensionContext ec, Throwable throwable) {
    testFailed++;
    String testName = ec.getDisplayName();
    testName = testName.substring(0, testName.length() - 2);
    String scr_message = "";
    if (ScreenShot.isSaveScreenshots() == true) {
      scr_message = "\n" + "                   screenshot -> " +
              testName + ".png";
      ScreenShot.takeScreenShot(testName);
    }
    String message = "         " + Const.ERROR_LABEL
            + getExceptionMessage(throwable.getMessage()) + scr_message;
    ProjectLogger.logger.error(message);
  }

  /**
   * Activity, when test pass
   */
  @Override
  public void testSuccessful(ExtensionContext ec) {
    testPassed++;
    // the name of the test is logged before the test begins
  }

}

Tato třída se v každém z testů aktivuje již známým způsobem anotací  @ExtendWith.

Příklad použití:

@ExtendWith(TestSetting.class)
@ExtendWith(TestEvaluation.class)
@DisplayName("Student My Exam Dates Tests")
@Tag(ACTIVE)
@Tag(ONE_ACTIVITY)
public class Stu_MyExamDates_Test extends ActiveTestSupport {
  @Test
  @DisplayName("Several exam dates")

5. Parametrizované testy

Pokud se má test opakovat s malými obměnami, pak je parametrizovaný test vhodným řešením.

Poznámka:
Tyto typy testů byly již v JUnit4, ale v JUnit5 mají významně rozšířené možnosti. Jako jednu z velkých výhod vidím to, že v JUnit5 lze mít v jedné třídě testů parametrizované testy i testy „normální“.

Možností datových zdrojů jako parametrů testu je mnoho (viz manuál JUnit5). Já jsem v projektu používal dva typy zdrojů.

CSV zdroj v hlavičce testu

CSV zdroj je nejjednodušší způsob, kdy „natvrdo“ zapíšeme parametry testu přímo do jeho hlavičky. Hodnoty jsou uváděny jako řádky v CSV (je možné mít i více než jeden parametr). Datové typy se konvertují ze String do požadovaného (běžného) typu automaticky, v případě vlastních typů je možné si napsat konvertor. Konverze ale v této ukázce nebyla potřeba.

V ukázce je negativní test testující meze ( 0 a 11) počtu účastníků zkoušky a také reakci na zadání nečíselné hodnoty ( ab).

@ParameterizedTest
@Tag(NEGATIVE)
@Tag(ONE_ACTIVITY)
@DisplayName("Wrong number of participants")
@CsvSource({ "0",
             "11",
             "ab",
          })
void test_1(String participants) {
  page.setSelectionSubject("Programming in Java");
  page.setParticipantsSpinnerText(participants);
  page.clickOnSaveButton();
  Tea_NewExamDates_Check.checkNewErrorAlert();
}

Datový zdroj je metoda

V případě, že chceme použít programové generování parametrů testu, je vhodným datovým zdrojem metoda (z libovolné jiné třídy). V příkladu je to statická metoda getUsers() vracející List<String[]> ze třídy Parameters. Každý prvek tohoto seznamu (tj. pole String) představuje jednu „řádku“ parametrů testovací metody. Jednotlivé řetězce z tohoto pole se automaticky namapují na formální parametry testovací metody, tj. na String userName , String positionString fullName.

Spojení datového zdroje s testovací metodou je pomocí anotace  @MethodSource.

@ParameterizedTest(name = "{index}: user={0}; position={1}; name={2}")
@Tag(PASSIVE)
@Tag(DB_CONTENT)
@MethodSource("uis.support.db.support.Parameters#getUsers")
void test(String userName, String position, String fullName) {
  loginPage.setUsername(userName).
          setPassword(Const.CORRECT_PASSWORD).clickOnLoginButton();

  String expectedURI;
  String actualFullName;

  if (position.equals(Parameters.TEACHER_POSITION) == true) {
    expectedURI = Url.URL_TEA_OVERVIEW;
    actualFullName = new Tea_Overview_Page().header.getFullNameText();
  }
  else {
    expectedURI = Url.URL_STU_OVERVIEW;
    actualFullName = new Stu_Overview_Page().header.getFullNameText();
  }

  Check.checkActualUrl(expectedURI);
  Check.checkText(actualFullName, fullName, Header_Page.FULL_NAME_LINK);
}

6. Více assertů v jednom testu

Pokud píšeme jednotkové testy, pak je tento přístup většinou chybný, protože u jednotkových testů má jeden test pokrývat ideálně jen a pouze jednu právě testovanou vlastnost, tj. má jen jeden assert (jedno porovnání). U funkcionálních testů není mnohdy výhodné mít separátní test pro každý assert. U funkcionálních testů potřebujeme často najednou ověřit celý stav zkoumaného objektu. To v JUnit5 umožňuje metoda assertAll(), která zajišťuje spuštění všech v ní obsažených assertů (obecně příkazů) nezávisle na tom, zda některý z nich selže či nikoliv.

Této možnosti jsem s výhodou využil v aktivních testech, kdy těla testů jsou velmi jednoduchá a končí voláním metody  checkWholeContent().

@ExtendWith(TestSetting.class)
@ExtendWith(TestEvaluation.class)
@DisplayName("Student My Exam Dates Tests")
@Tag(ACTIVE)
@Tag(ONE_ACTIVITY)
public class Stu_MyExamDates_Test extends ActiveTestSupport {
  @Test
  @DisplayName("Several exam dates")
  void test_1() {
    setParameters("cyan", "strict", "Programming in Java");
    Stu_MyExamDates_Actions.unregisterExamDate(student, subject);
    checkWholeContent();
  }

Zmíněná metoda obsahuje pouze jednu metodu assertAll(), která postupně volá jednotlivé kontrolní metody pro každou webovou stránku zvlášť.

/**
 * Performs all known checks from Services
 */
public void checkWholeContent() {
  assertAll("UIS is NOT in a proper state",
     // Student view
     () -> Stu_Overview_Check.checkFirstname(student),
     () -> Stu_Overview_Check.checkLastname(student),
     () -> Stu_Overview_Check.checkEmail(student),
     () -> Stu_MySubjects_Check.checkEnrolledSubjects(student),
     () -> Stu_MySubjects_Check.checkTotalCredits(student),
     () -> Stu_MySubjects_Check.checkCompletedSubjects(student),
     () -> Stu_MySubjects_Check.checkCompletedSubjectsAndGrades(student),
     () -> Stu_OtherSubjects_Check.checkOtherSubjects(student),
     () -> Stu_MyExamDates_Check.checkSubjectsWithExamDate(student),
     () -> Stu_MyExamDates_Check.checkStudentAsParticipant(student),
     () -> Stu_OtherExamDates_Check.checkAllSubjects(student),
     // Teacher view
     () -> Tea_Overview_Check.checkFirstname(teacher),
     () -> Tea_Overview_Check.checkLastname(teacher),
     () -> Tea_Overview_Check.checkEmail(teacher),
     () -> Tea_MySubjects_Check.checkTaughtSubjects(teacher),
     () -> Tea_MyExamDates_Check.checkSubjectsAndExamDates(teacher),
     () -> Tea_NewExamDates_Check.checkSubjects(teacher),
     () -> Tea_SetEval_Check.checkParticipantsOnExamTerm(teacher, subject, examDate),
     () -> Tea_EvalTable_Check.checkEvaluationForSubjectsAndExamDates(teacher, student),
     () -> Tea_OthersSubjects_Check.checkOthersSubjects(teacher)
  );
}

Poznámka:
Díky tomuto mechanismu se u aktivních testů tolik liší počet testů (64) od počtu assertů (2351) – viz dříve.

7. Šablony testů

Toto je značně sofistikovaná akce, kterou by bylo možné funkčně realizovat například pomocí výše uvedené metody assertAll(). Použité řešení je ale více flexibilní, ovšem poněkud málo srozumitelné.

V předchozím článku o PageObject bylo ukázáno, jak lze opakující se části webové stránky popsat v jedné třídě a tuto třídu následně pomocí kompozice (skládání) využít v jiné třídě. Tento přístup lze použít i pro opakované testy těchto částí.

Pokud budu konkrétní, pak testy hlavičky (header), a obou menu se dají opakovat. Teoreticky by sice tyto části stačilo otestovat jen na jedné stránce a na ostatních stránkách už jen předpokládat, že fungují stejně správně.

Chceme-li však být důslední, pak je nezbytné provést tyto testy na každé zobrazené stránce znovu.

Pro tento účel lze relativně snadno připravit šablonu testů, která se od běžných testů liší jen použitím anotace @TestTemplate místo běžného  @Test.

Příklad je krácen.

@DisplayName("Header: PageContent")
public class Header_Page__Template_PageContent {

  /////// User's full name
  @TestTemplate
  @DisplayName("FullName tooltip")
  void fullName_tooltip(Header_Page headerPage) {
    Check.checkTooltip(headerPage.getFullNameTooltipText(), headerPage.TXT_FULL_NAME_LINK_T,
            headerPage.FULL_NAME_LINK);
  }

  /////// logout
  @TestTemplate
  @DisplayName("Logout tooltip")
  void logout_tooltip(Header_Page headerPage) {
    Check.checkTooltip(headerPage.getLogoutTooltipText(), headerPage.TXT_LOGOUT_LINK_T,
            headerPage.LOGOUT_LINK);
  }

  @TestTemplate
  @DisplayName("Logout text")
  void logout_text(Header_Page headerPage) {
    String actual = headerPage.getLogoutText();
    Check.checkText(actual, headerPage.TXT_LOGOUT_LINK,
            headerPage.LOGOUT_LINK);
  }

}

Zde ovšem jednoduchost končí, protože následně je třeba připravit „datový zdroj“ pro tyto šablony.

Příklad je zkopírován z návodu JUnit5 a upraven. Není krácen a je v něm vyznačeno, co je nutné změnit pro konkrétní šablonu.

abstract public class Header_Page__ParamProvider
        implements TestTemplateInvocationContextProvider {
  @Override
  public boolean supportsTestTemplate(ExtensionContext context) {
    return true;
  }

  @Override
  abstract public Stream provideTestTemplateInvocationContexts(
          ExtensionContext context);

  protected TestTemplateInvocationContext invocationContext(Header_Page headerPage) {
    return new TestTemplateInvocationContext() {
      @Override
      public String getDisplayName(int invocationIndex) {
        return "HeaderPage";
      }

      @Override
      public List getAdditionalExtensions() {
        return Collections.singletonList(new ParameterResolver() {
          @Override
          public boolean supportsParameter(
                  ParameterContext parameterContext,
                  ExtensionContext extensionContext) {
            return parameterContext.getParameter().getType()
                    .equals(Header_Page.class);
          }

          @Override
          public Header_Page resolveParameter(
                  ParameterContext parameterContext,
                  ExtensionContext extensionContext) {
            return headerPage;
          }
        });
      }
    };
  }
}

Pokud máme připravenou šablonu i „datový zdroj“, je možné „inkludovat“ tyto šablony testů do testů každé stránky.

Aby byl datový zdroj správně nastavený na příslušnou webovou stránku, která obsahuje header, je třeba datový zdroj zdědit ( HeaderPage_Data).

Následně se využije možnosti vnořených testů (anotace @Nested), kdy se oddědí od šablony testů nová třída, které je pak možné již známým mechanizmem extensions přidat aktuální datový zdroj.

public class PageContent_Test {
  static Stu_Overview_Page page;

  @BeforeAll
  public static void setUpBeforeAll() {
    Click.openUrlAndWait(Url.URL_LOGIN);
    Login_Page login = new Login_Page();
    login.setUsername(STUDENT).
            setPassword(Const.CORRECT_PASSWORD).clickOnLoginButton();
    page = new Stu_Overview_Page();
  }
  @Test
  @DisplayName("Actual URL")
  @Tag(SMOKE)
  void actualURL() {
    Check.checkActualUrl(Url.URL_STU_OVERVIEW);
  }

  ////////////////// Header
  static class HeaderPage_Data extends Header_Page__ParamProvider {
    @Override
    public Stream
                             provideTestTemplateInvocationContexts(
                                           ExtensionContext context) {
      return Stream.of(invocationContext(page.header));
    }
  }

  @Nested
  @DisplayName("Header test")
  @ExtendWith(HeaderPage_Data.class)
  class HeaderPageTemplateTest extends Header_Page__Template_PageContent {
  }
}

Testy ze šablony jsou pak provedeny jako běžné ostatní okolní testy (zde např. test „Actual URL“).

Poznámka:
Šablony, respektive jejich použití, nejsou zrovna ukázkou čitelnosti a srozumitelnosti testů. Naštěstí je nutné tento zápis připravit pouze jednou a do ostatních testovacích tříd jej pouze nakopírovat.
Ponechám na čtenáři, aby sám uvážil užitečnost tohoto přístupu např. v porovnání s již známým  assertAll().

8. Externí spouštění testů

V okamžiku, kdy bude sada testů dokončena a odladěna, je možné ji prohlásit za hotovou a pak bude výhodné ji moci spouštět buď z příkazové řádky, nebo z nějaké GUI nadstavby.

JUnit5 dává možnost spouštět testy externě. Díky vyhodnocování testů pomocí TestWatcher (viz výše) není nutné využívat žádné existující přednastavené listenery případně si psát vlastní. Stačí testy pouze spustit.

Konfigurace toho, co bude ve skutečnosti spuštěno, má množství možností popsaných v JavaDoc třídy LauncherDiscoveryRequestBuilder. Pro použití v tomto projektu budou stačit čtyři základní možnosti:

  • spustit testy z libovolného balíku či podbalíku,
  • spustit testy z libovolné třídy,
  • z množiny potenciálně spustitelných testů vybrat jen ty, které jsou označeny zvolenými tagy,
  • z množiny potenciálně spustitelných testů zabránit spuštění určitých otagovaných testů.

Pro tuto aktivitu jsem připravil třídu RunnerAny, která je schopná přijmout parametry z příkazové řádky a podle nich nastavit příslušné selektory a filtery třídy  LauncherDiscoveryRequestBuilder.

Použití třídy je zřejmé z její nápovědy:

public class RunnerAny {

  public static void main(String[] args) {
    if (args.length == 0) {
      help();
      return;
    }
    String packageName = "";
    String className = "";
    List includeTags = new ArrayList<>();
    List excludeTags = new ArrayList<>();

    for (int i = 0;  i < args.length;  i += 2) {
      switch (args[i]) {
        case "p":  // package
          packageName = args[i + 1];
          break;
        case "c":  // class
          className = args[i + 1];
          break;
        case "i":  // include tag
          includeTags.add(args[i + 1]);
          break;
        case "e":  // exclude tag
          excludeTags.add(args[i + 1]);
          break;
      }
    }

    String[] include = includeTags.toArray(new String[0]);
    String[] exclude = excludeTags.toArray(new String[0]);

    LauncherDiscoveryRequestBuilder builder = LauncherDiscoveryRequestBuilder.request();
    if (packageName.isEmpty() == false) {
      builder.selectors(selectPackage(packageName));
    }
    else if (className.isEmpty() == false) {
      builder.selectors(selectClass(className));
    }
    if (include.length > 0) {
      builder.filters(includeTags(include));
    }
    if (exclude.length > 0) {
      builder.filters(excludeTags(exclude));
    }

    LauncherDiscoveryRequest request = builder.build();
    Launcher launcher = LauncherFactory.create();
    launcher.execute(request);
  }

  public static void help() {
    System.out.println("Usage:");
    System.out.println("RunnerAny p packageName | c className [i includeTag]* [e excludeTag]*");
    System.out.println("Examples:");
    System.out.println("RunnerAny p uis.functional_tests");
    System.out.println("RunnerAny p uis.functional_tests i SMOKE e FAIL");
    System.out.println("RunnerAny c uis.functional_tests.active.scenario.Scenario_Test" +
            " i DB_CONTENT i PAGE_CONTENT e NO_RECORDS e BOUNDARY");
  }
}

Závěr

Možnosti JUnit5 jsou značné a dokáží zpřehlednit a zjednodušit zápis testů. Dle mého názoru je výhodné jej začít v nových projektech používat. JUnit5 dává i možnost spouštění testů napsaných pro JUnit4, což může být pro někoho zajímavá možnost.

Děkuji všem čtenářům za jejich trpělivost. Doufám, že se dozvěděli užitečné informace. A na samý závěr ještě zopakuji svoji nabídku z první části této minisérie. Pokud vás z této problematiky něco více zaujalo a potřebovali byste podrobnější informace, je možné se domluvit na osobní konzultaci nebo i na nějaké formě školení.