Scrittura di modelli per casi di test utilizzando JUnit 5

1. Panoramica

La libreria JUnit 5 offre molte nuove funzionalità rispetto alle versioni precedenti. Una di queste funzionalità sono i modelli di test. In breve, i modelli di test sono una potente generalizzazione dei test parametrizzati e ripetuti di JUnit 5.

In questo tutorial, impareremo come creare un modello di test utilizzando JUnit 5.

2. Dipendenze di Maven

Cominciamo aggiungendo le dipendenze al nostro pom.xml .

Dobbiamo aggiungere la principale dipendenza JUnit 5 junit-jupiter-engine :

 org.junit.jupiter junit-jupiter-engine 5.7.0 

Oltre a questo, dovremo anche aggiungere la dipendenza junit-jupiter-api :

 org.junit.jupiter junit-jupiter-api 5.7.0 

Allo stesso modo, possiamo aggiungere le dipendenze necessarie al nostro file build.gradle :

testCompile group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: '5.7.0' testCompile group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.7.0'

3. La dichiarazione del problema

Prima di esaminare i modelli di test, diamo brevemente un'occhiata ai test parametrizzati di JUnit 5. I test parametrizzati ci consentono di inserire diversi parametri nel metodo di prova. Di conseguenza, quando si utilizzano test parametrizzati, è possibile eseguire più volte un singolo metodo di test con parametri diversi.

Supponiamo di voler eseguire il nostro metodo di test più volte, non solo con parametri diversi, ma anche in un contesto di chiamata diverso ogni volta.

In altre parole, vorremmo che il metodo di test venga eseguito più volte, con ogni invocazione utilizzando una diversa combinazione di configurazioni come:

  • utilizzando parametri diversi
  • preparare l'istanza della classe di test in modo diverso, ovvero iniettare dipendenze diverse nell'istanza di test
  • eseguire il test in condizioni diverse, come abilitare / disabilitare un sottoinsieme di invocazioni se l'ambiente è " QA "
  • in esecuzione con un diverso comportamento di callback del ciclo di vita - forse vogliamo impostare e abbattere un database prima e dopo un sottoinsieme di invocazioni

L'utilizzo di test parametrizzati si rivela rapidamente limitato in questo caso. Per fortuna, JUnit 5 offre una potente soluzione per questo scenario sotto forma di modelli di test.

4. Modelli di prova

I modelli di test stessi non sono casi di test. Invece, come suggerisce il nome, sono solo modelli per determinati casi di test. Sono una potente generalizzazione di test parametrizzati e ripetuti.

I modelli di test vengono richiamati una volta per ogni contesto di chiamata fornito loro dai provider del contesto di chiamata.

Vediamo ora un esempio dei modelli di test. Come stabilito sopra, gli attori principali sono:

  • un metodo target di prova
  • un metodo del modello di test
  • uno o più provider di contesto di chiamata registrati con il metodo modello
  • uno o più contesti di chiamata forniti da ciascun provider di contesto di chiamata

4.1. Il metodo del target di prova

Per questo esempio, utilizzeremo un semplice metodo UserIdGeneratorImpl.generate come obiettivo del test.

Definiamo la classe UserIdGeneratorImpl :

public class UserIdGeneratorImpl implements UserIdGenerator { private boolean isFeatureEnabled; public UserIdGeneratorImpl(boolean isFeatureEnabled) { this.isFeatureEnabled = isFeatureEnabled; } public String generate(String firstName, String lastName) { String initialAndLastName = firstName.substring(0, 1).concat(lastName); return isFeatureEnabled ? "bael".concat(initialAndLastName) : initialAndLastName; } }

Il metodo generate , che è il nostro obiettivo di test, accetta firstName e lastName come parametri e genera un ID utente. Il formato dell'ID utente varia a seconda che un interruttore di funzionalità sia abilitato o meno.

Vediamo come appare:

Given feature switch is disabled When firstName = "John" and lastName = "Smith" Then "JSmith" is returned Given feature switch is enabled When firstName = "John" and lastName = "Smith" Then "baelJSmith" is returned

Quindi, scriviamo il metodo del modello di test.

4.2. Il metodo del modello di test

Ecco un modello di test per il nostro metodo di destinazione del test UserIdGeneratorImpl.generate :

public class UserIdGeneratorImplUnitTest { @TestTemplate @ExtendWith(UserIdGeneratorTestInvocationContextProvider.class) public void whenUserIdRequested_thenUserIdIsReturnedInCorrectFormat(UserIdGeneratorTestCase testCase) { UserIdGenerator userIdGenerator = new UserIdGeneratorImpl(testCase.isFeatureEnabled()); String actualUserId = userIdGenerator.generate(testCase.getFirstName(), testCase.getLastName()); assertThat(actualUserId).isEqualTo(testCase.getExpectedUserId()); } }

Diamo uno sguardo più da vicino al metodo del modello di test.

Per cominciare, creiamo il nostro metodo del modello di test contrassegnandolo con l' annotazione JUnit 5 @TestTemplate .

Successivamente, registriamo un provider di contesto , UserIdGeneratorTestInvocationContextProvider, utilizzando l' annotazione @ExtendWith . Possiamo registrare più provider di contesto con il modello di test. Tuttavia, ai fini di questo esempio, registriamo un unico fornitore.

Inoltre, il metodo del modello riceve un'istanza di UserIdGeneratorTestCase come parametro. Questa è semplicemente una classe wrapper per gli input e il risultato atteso del test case:

public class UserIdGeneratorTestCase { private boolean isFeatureEnabled; private String firstName; private String lastName; private String expectedUserId; // Standard setters and getters }

Infine, invochiamo il metodo di destinazione del test e affermiamo che il risultato è quello previsto

Ora è il momento di definire il nostro provider di contesto di chiamata .

4.3. Il provider del contesto di invocazione

Dobbiamo registrare almeno un TestTemplateInvocationContextProvider con il nostro modello di test. Ogni TestTemplateInvocationContextProvider registrato fornisce un flusso di istanze di TestTemplateInvocationContext .

Previously, using the @ExtendWith annotation, we registered UserIdGeneratorTestInvocationContextProvider as our invocation provider.

Let's define this class now:

public class UserIdGeneratorTestInvocationContextProvider implements TestTemplateInvocationContextProvider { //... }

Our invocation context implements the TestTemplateInvocationContextProvider interface, which has two methods:

  • supportsTestTemplate
  • provideTestTemplateInvocationContexts

Let's start by implementing the supportsTestTemplate method:

@Override public boolean supportsTestTemplate(ExtensionContext extensionContext) { return true; }

The JUnit 5 execution engine calls the supportsTestTemplate method first to validate if the provider is applicable for the given ExecutionContext. In this case, we simply return true.

Now, let's implement the provideTestTemplateInvocationContexts method:

@Override public Stream provideTestTemplateInvocationContexts( ExtensionContext extensionContext) { boolean featureDisabled = false; boolean featureEnabled = true; return Stream.of( featureDisabledContext( new UserIdGeneratorTestCase( "Given feature switch disabled When user name is John Smith Then generated userid is JSmith", featureDisabled, "John", "Smith", "JSmith")), featureEnabledContext( new UserIdGeneratorTestCase( "Given feature switch enabled When user name is John Smith Then generated userid is baelJSmith", featureEnabled, "John", "Smith", "baelJSmith")) ); }

The purpose of the provideTestTemplateInvocationContexts method is to provide a Stream of TestTemplateInvocationContext instances. For our example, it returns two instances, provided by the methods featureDisabledContext and featureEnabledContext. Consequently, our test template will run twice.

Next, let's look at the two TestTemplateInvocationContext instances returned by these methods.

4.4. The Invocation Context Instances

The invocation contexts are implementations of the TestTemplateInvocationContext interface and implement the following methods:

  • getDisplayName – provide a test display name
  • getAdditionalExtensions – return additional extensions for the invocation context

Let's define the featureDisabledContext method that returns our first invocation context instance:

private TestTemplateInvocationContext featureDisabledContext(   UserIdGeneratorTestCase userIdGeneratorTestCase) { return new TestTemplateInvocationContext() { @Override public String getDisplayName(int invocationIndex) { return userIdGeneratorTestCase.getDisplayName(); } @Override public List getAdditionalExtensions() { return asList( new GenericTypedParameterResolver(userIdGeneratorTestCase), new BeforeTestExecutionCallback() { @Override public void beforeTestExecution(ExtensionContext extensionContext) { System.out.println("BeforeTestExecutionCallback:Disabled context"); } }, new AfterTestExecutionCallback() { @Override public void afterTestExecution(ExtensionContext extensionContext) { System.out.println("AfterTestExecutionCallback:Disabled context"); } } ); } }; }

Firstly, for the invocation context returned by the featureDisabledContext method, the extensions that we register are:

  • GenericTypedParameterResolver – a parameter resolver extension
  • BeforeTestExecutionCallback – a lifecycle callback extension that runs immediately before the test execution
  • AfterTestExecutionCallback – a lifecycle callback extension that runs immediately after the test execution

However, for the second invocation context, returned by the featureEnabledContext method, let's register a different set of extensions (keeping the GenericTypedParameterResolver):

private TestTemplateInvocationContext featureEnabledContext(   UserIdGeneratorTestCase userIdGeneratorTestCase) { return new TestTemplateInvocationContext() { @Override public String getDisplayName(int invocationIndex) { return userIdGeneratorTestCase.getDisplayName(); } @Override public List getAdditionalExtensions() { return asList( new GenericTypedParameterResolver(userIdGeneratorTestCase), new DisabledOnQAEnvironmentExtension(), new BeforeEachCallback() { @Override public void beforeEach(ExtensionContext extensionContext) { System.out.println("BeforeEachCallback:Enabled context"); } }, new AfterEachCallback() { @Override public void afterEach(ExtensionContext extensionContext) { System.out.println("AfterEachCallback:Enabled context"); } } ); } }; }

For the second invocation context, the extensions that we register are:

  • GenericTypedParameterResolver – a parameter resolver extension
  • DisabledOnQAEnvironmentExtension – an execution condition to disable the test if the environment property (loaded from the application.properties file) is “qa
  • BeforeEachCallback – a lifecycle callback extension that runs before each test method execution
  • AfterEachCallback – a lifecycle callback extension that runs after each test method execution

From the above example, it is clear to see that:

  • the same test method is run under multiple invocation contexts
  • each invocation context uses its own set of extensions that differ both in number and nature from the extensions in other invocation contexts

As a result, a test method can be invoked multiple times under a completely different invocation context each time. And by registering multiple context providers, we can provide even more additional layers of invocation contexts under which to run the test.

5. Conclusion

In this article, we looked at how JUnit 5's test templates are a powerful generalization of parameterized and repeated tests.

Per cominciare, abbiamo esaminato alcune limitazioni dei test parametrizzati. Successivamente, abbiamo discusso in che modo i modelli di test superano i limiti consentendo l'esecuzione di un test in un contesto diverso per ogni chiamata.

Infine, abbiamo esaminato un esempio di creazione di un nuovo modello di test. Abbiamo analizzato l'esempio per capire come funzionano i modelli insieme ai provider di contesto di chiamata e ai contesti di chiamata.

Come sempre, il codice sorgente per gli esempi utilizzati in questo articolo è disponibile su GitHub.