Unit Testing, detaillierte Betrachtung parametrisierter Tests. Teil I.

Guten Tag, Kollegen.



Ich habe mich entschlossen, meine Vision von parametrisierten Komponententests zu teilen, wie wir es machen und wie Sie es wahrscheinlich nicht tun (aber wollen).



Ich würde gerne einen schönen Satz darüber schreiben, was richtig getestet werden sollte, und Tests sind wichtig, aber es wurde bereits viel Material vor mir gesagt und geschrieben. Ich werde nur versuchen, zusammenzufassen und hervorzuheben, wofür meiner Meinung nach die Leute selten (verstehen), wofür im Grunde zieht ein.



Das Hauptziel des Artikels ist es zu zeigen, wie Sie aufhören können (und sollten), Ihren Komponententest mit Code zum Erstellen von Objekten zu überladen, und wie Sie deklarativ Testdaten erstellen, wenn mock (any ()) nicht ausreicht, und es gibt viele solcher Situationen.



Lassen Sie uns ein Maven-Projekt erstellen, junit5, junit-jupiter-params und mokito hinzufügen.



Damit es nicht völlig langweilig wird, werden wir gleich nach dem Test mit dem Schreiben beginnen. Wie TDD-Apologeten möchten, benötigen wir einen Service, den wir deklarativ testen, jeder wird es tun, sei es HabrService.



Lassen Sie uns einen Test HabrServiceTest erstellen. Fügen Sie im Feld Testklasse einen Link zum HabrService hinzu:



public class HabrServiceTest {

    private HabrService habrService;

    @Test
    void handleTest(){

    }
}


Erstellen Sie einen Dienst über ide (indem Sie leicht auf die Verknüpfung drücken), und fügen Sie dem Feld die Annotation @InjectMocks hinzu.



Beginnen wir direkt mit dem Test: Der HabrService in unserer kleinen Anwendung verfügt über eine einzige handle () -Methode, die ein einziges HabrItem-Argument akzeptiert, und jetzt sieht unser Test folgendermaßen aus:



public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @Test
    void handleTest(){
        HabrItem item = new HabrItem();
        habrService.handle(item);
    }
}


Fügen wir HabrService eine handle () -Methode hinzu, die die ID eines neuen Posts in Habré zurückgibt, nachdem dieser moderiert und in der Datenbank gespeichert wurde, und den Typ HabrItem verwendet. Wir erstellen auch unser HabrItem, und jetzt kompiliert der Test, stürzt jedoch ab.



Der Punkt ist, dass wir eine Prüfung für den erwarteten Rückgabewert hinzugefügt haben.



public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp(){
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem item = new HabrItem();
        Long actual = habrService.handle(item);

        assertEquals(1L, actual);
    }
}


Außerdem möchte ich sicherstellen, dass beim Aufruf der handle () -Methode ReviewService und PersistanceService aufgerufen wurden, sie streng nacheinander aufgerufen wurden, genau einmal arbeiteten und keine anderen Methoden mehr aufgerufen wurden. Mit anderen Worten, so:



public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp(){
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem item = new HabrItem();
        
        Long actual = habrService.handle(item);
        
        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(item);
        inOrder.verify(persistenceService).makePersist(item);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }
}


Fügen Sie den Klassenfeldern reviewService und persistenceService hinzu, erstellen Sie sie und fügen Sie ihnen die Methoden makeRewiew () und makePersist () hinzu. Jetzt wird alles kompiliert, aber der Test ist natürlich rot.



Im Kontext dieses Artikels sind die Implementierungen ReviewService und PersistanceService nicht so wichtig, die Implementierung von HabrService ist wichtig. Machen wir es etwas interessanter als jetzt:



public class HabrService {

    private final ReviewService reviewService;

    private final PersistenceService persistenceService;

    public HabrService(final ReviewService reviewService, final PersistenceService persistenceService) {
        this.reviewService = reviewService;
        this.persistenceService = persistenceService;
    }

    public Long handle(final HabrItem item) {
        HabrItem reviewedItem = reviewService.makeRewiew(item);
        Long persistedItemId = persistenceService.makePersist(reviewedItem);

        return persistedItemId;
    }
}


und mit when (). then () -Konstrukten sperren wir das Verhalten von Hilfskomponenten. Infolgedessen wurde unser Test so und jetzt ist er grün:



public class HabrServiceTest {

    @Mock
    private ReviewService reviewService;

    @Mock
    private PersistenceService persistenceService;

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp() {
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem source = new HabrItem();
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }
}


Ein Modell zur Demonstration der Leistungsfähigkeit parametrisierter Tests ist fertig.



Fügen Sie unserem Anforderungsmodell für den HabrItem-Dienst ein Feld mit dem Hub-Typ hubType hinzu, erstellen Sie eine Aufzählung HubType und fügen Sie mehrere Typen hinzu:



public enum HubType {
    JAVA, C, PYTHON
}


Fügen Sie für das HabrItem-Modell dem erstellten HubType-Feld einen Getter und einen Setter hinzu.



Angenommen, ein Switch ist in den Tiefen unseres HabrService versteckt, der je nach Hub-Typ etwas Unbekanntes mit der Anforderung tut, und im Test, den wir für jeden Fall des Unbekannten testen möchten, würde die naive Implementierung der Methode folgendermaßen aussehen:



        
    @Test
    void handleTest() {
        HabrItem reviewedItem = mock(HabrItem.class);
        HabrItem source = new HabrItem();
        source.setHubType(HubType.JAVA);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }


Sie können es etwas hübscher und bequemer machen, indem Sie den Test parametrisieren und einen zufälligen Wert aus unserer Aufzählung als Parameter hinzufügen. Infolgedessen sieht die Testdeklaration folgendermaßen aus:



@ParameterizedTest
    @EnumSource(HubType.class)
    void handleTest(final HubType type) 


schön, deklarativ, und alle Werte unserer Aufzählung werden definitiv bei einem nächsten Testlauf verwendet. Die Anmerkung enthält Parameter. Wir können Strategien für Einschließen, Ausschließen hinzufügen.



Aber vielleicht habe ich Sie nicht davon überzeugt, dass parametrisierte Tests gut sind. Hinzufügen

Die ursprüngliche HabrItem-Anfrage, ein neues editCount-Feld, in das die Anzahl der tausend Male, die Habr-Benutzer ihren Artikel bearbeiten, vor dem Posten geschrieben wird, damit es Ihnen zumindest ein wenig gefällt, und angenommen, dass es irgendwo in den Tiefen von HabrService eine Art Logik gibt, die das Unbekannte tut Je nachdem, wie viel der Autor versucht hat, was ist, wenn ich nicht 5 oder 55 Tests für alle möglichen editCount-Optionen schreiben möchte, sondern deklarativ testen möchte und irgendwo an einer Stelle sofort alle Werte angeben möchte, die ich überprüfen möchte ... Einfacher geht es nicht, und mit der API parametrisierter Tests erhalten wir in der Methodendeklaration Folgendes:



    @ParameterizedTest
    @ValueSource(ints = {0, 5, 14, 23})
    void handleTest(final int type) 


Es gibt ein Problem, wir möchten zwei Werte gleichzeitig deklarativ in den Parametern der Testmethode sammeln. Sie können eine andere hervorragende Methode für parametrisierte Tests @CsvSource verwenden, die sich perfekt zum Testen einfacher Parameter mit einem einfachen Ausgabewert eignet (äußerst praktisch zum Testen von Dienstprogrammklassen), aber was ob das Objekt viel komplizierter wird? Angenommen, es enthält ungefähr 10 Felder und nicht nur Grundelemente und Java-Typen.



Die Annotation @MethodSource hilft, unsere Testmethode ist merklich kürzer geworden und es sind keine Setter mehr darin enthalten, und die Quelle der eingehenden Anforderung wird der Testmethode als Parameter zugeführt:



    
    @ParameterizedTest
    @MethodSource("generateSource")
    void handleTest(final HabrItem source) {
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }


Die Annotation @MethodSource enthält die Zeichenfolge generateSource. Was ist das? Dies ist der Name der Methode, die das für uns erforderliche Modell sammelt. Die Deklaration sieht folgendermaßen aus:



   private static Stream<Arguments> generateSource() {
        HabrItem habrItem = new HabrItem();
        habrItem.setHubType(HubType.JAVA);
        habrItem.setEditCount(999L);
        
        return nextStream(() -> habrItem);
    }


Der Einfachheit halber habe ich die Bildung eines Streams von nextStream-Argumenten in eine separate Utility-Testklasse verschoben:



public class CommonTestUtil {
    private static final Random RANDOM = new Random();

    public static <T> Stream<Arguments> nextStream(final Supplier<T> supplier) {
        return Stream.generate(() -> Arguments.of(supplier.get())).limit(nextIntBetween(1, 10));
    }

    public static int nextIntBetween(final int min, final int max) {
        return RANDOM.nextInt(max - min + 1) + min;
    }
}


Wenn Sie den Test starten, wird das HabrItem-Anforderungsmodell deklarativ zum Parameter der Testmethode hinzugefügt, und der Test wird so oft gestartet, wie die Anzahl der von unserem Testdienstprogramm generierten Argumente, in unserem Fall von 1 bis 10.



Dies kann besonders praktisch sein, wenn sich das Modell im Argumentstrom befindet wird nicht wie in unserem Beispiel per Hardcode gesammelt, sondern mit Hilfe von Randomisierern (Es lebe der Floating-Test, aber wenn ja, gibt es auch ein Problem).



Meiner Meinung nach ist schon alles super, der Test beschreibt nur noch das Verhalten unserer Stubs und die erwarteten Ergebnisse.



Aber hier ist das Pech, ein neues Feld, Text, eine Reihe von Zeichenfolgen wird dem HabrItem-Modell hinzugefügt, das sehr groß sein kann oder nicht, es spielt keine Rolle, die Hauptsache ist, dass wir unsere Tests nicht überladen wollen, wir brauchen keine zufälligen Daten, wir wollen ein streng definiertes Modell, Sammeln Sie bestimmte Daten in einem Test oder an einem anderen Ort - wir wollen nicht. Es wäre cool, wenn Sie den Text einer JSON-Anfrage von überall her übernehmen könnten, beispielsweise von einem Postboten, eine darauf basierende Scheindatei erstellen und im Test deklarativ ein Modell bilden könnten, indem Sie nur den Pfad zur JSON-Datei mit Daten angeben.



Ausgezeichnet. Wir verwenden die Annotation @JsonSource, die einen Pfadparameter mit einem relativen Dateipfad und einer Zielklasse verwendet. Teufel! In parametrisierten Tests gibt es keine solche Anmerkung, aber ich würde gerne.



Lass es uns selbst schreiben.



ArgumentsProvider ist für die Verarbeitung aller Anmerkungen verantwortlich, die mit @ParametrizedTest in junit geliefert werden. Wir werden unseren eigenen JsonArgumentProvider schreiben:



public class JsonArgumentProvider implements ArgumentsProvider, AnnotationConsumer<JsonSource> {

    private String path;

    private MockDataProvider dataProvider;

    private Class<?> clazz;

    @Override
    public void accept(final JsonSource jsonSource) {
        this.path = jsonSource.path();
        this.dataProvider = new MockDataProvider(new ObjectMapper());
        this.clazz = jsonSource.clazz();
    }

    @Override
    public Stream<Arguments> provideArguments(final ExtensionContext context) {
        return nextSingleStream(() -> dataProvider.parseDataObject(path, clazz));
    }
}


MockDataProvider ist eine Klasse zum Parsen von Mock-JSON-Dateien. Die Implementierung ist äußerst einfach:




public class MockDataProvider {

    private static final String PATH_PREFIX = "json/";

    private final ObjectMapper objectMapper;

     public <T> T parseDataObject(final String name, final Class<T> clazz) {
        return objectMapper.readValue(new ClassPathResource(PATH_PREFIX + name).getInputStream(), clazz);
    }

}


Der Scheinanbieter ist bereit, der Argumentanbieter auch für unsere Annotation. Es bleibt noch die Annotation selbst hinzuzufügen:




/**
 * Source-   ,
 *     json-
 */
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(JsonArgumentProvider.class)
public @interface JsonSource {

    /**
     *   json-,   classpath:/json/
     *
     * @return     
     */
    String path() default "";

    /**
     *  ,        
     *
     * @return  
     */
    Class<?> clazz();
}


Hurra. Unsere Anmerkung ist gebrauchsfertig, die Testmethode lautet jetzt:



  
    @ParameterizedTest
    @JsonSource(path = MOCK_FILE_PATH, clazz = HabrItem.class)
    void handleTest(final HabrItem source) {
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }


In Mock Json können wir so viele und sehr schnell eine Reihe von Objekten produzieren, die wir benötigen, und von nun an gibt es keinen Code, der vom Wesen des Tests ablenkt. Für die Bildung von Testdaten kann man natürlich oft mit Mocks arbeiten, aber nicht immer.



Zusammenfassend möchte ich Folgendes sagen: Oft arbeiten wir jahrelang so, wie wir es früher getan haben, ohne darüber nachzudenken, dass einige Dinge schön und einfach erledigt werden können, oft unter Verwendung der Standard-API von Bibliotheken, die wir seit Jahren verwenden, aber nicht alle ihre Fähigkeiten kennen.



PS Der Artikel ist kein Versuch, TDD-Konzepte zu kennen. Ich wollte der Storytelling-Kampagne Testdaten hinzufügen, um sie etwas klarer und interessanter zu gestalten.



All Articles