Java >> Java Tutorial >  >> Java

Drei Gründe, warum wir die Vererbung in unseren Tests nicht verwenden sollten

Wenn wir automatisierte Tests (entweder Einheiten- oder Integrationstests) für unsere Anwendung schreiben, sollten wir das ziemlich bald bemerken

  1. Viele Testfälle verwenden dieselbe Konfiguration, wodurch doppelter Code entsteht.
  2. Das Erstellen von Objekten, die in unseren Tests verwendet werden, erzeugt doppelten Code.
  3. Das Schreiben von Behauptungen erzeugt doppelten Code.

Das erste, was einem in den Sinn kommt, ist, den doppelten Code zu eliminieren. Wie wir wissen, besagt das Don't Repeat Yourself (DRY)-Prinzip Folgendes:

Jedes Wissen muss eine einzige, eindeutige, maßgebliche Repräsentation innerhalb eines Systems haben.

Also machen wir uns an die Arbeit und entfernen den doppelten Code, indem wir eine Basisklasse (oder -klassen) erstellen, die unsere Tests konfiguriert und ihren Unterklassen nützliche Testdienstmethoden bereitstellt.

Leider ist dies eine sehr naive Lösung . Lesen Sie weiter und ich werde drei Gründe vorstellen, warum wir die Vererbung in unseren Tests nicht verwenden sollten.

1. Vererbung ist nicht das richtige Werkzeug zur Wiederverwendung von Code

DZone hat ein sehr gutes Interview mit Misko Hevery veröffentlicht, in dem er erklärt, warum Vererbung nicht das richtige Werkzeug zur Wiederverwendung von Code ist:

Der Sinn der Vererbung besteht darin, polymorphes Verhalten auszunutzen, NICHT Code wiederzuverwenden , und die Leute vermissen das, sie sehen Vererbung als eine billige Möglichkeit, einer Klasse Verhalten hinzuzufügen. Wenn ich Code entwerfe, denke ich gerne über Optionen nach. Wenn ich erbe, reduziere ich meine Optionen. Ich bin jetzt eine Unterklasse dieser Klasse und kann keine Unterklasse von etwas anderem sein. Ich habe meine Konstruktion dauerhaft auf die der Superklasse fixiert und bin den sich ändernden APIs der Superklasse ausgeliefert. Meine Änderungsfreiheit ist zur Kompilierzeit festgelegt.

Obwohl Misko Hevery über das Schreiben von testbarem Code sprach, denke ich, dass diese Regel auch für Tests gilt. Aber bevor ich erkläre, warum ich so denke, werfen wir einen genaueren Blick auf die Definition von Polymorphismus:

Polymorphismus ist die Bereitstellung einer einzigen Schnittstelle zu Entitäten verschiedener Typen.

Aus diesem Grund verwenden wir in unseren Tests keine Vererbung. Wir verwenden Vererbung, weil dies eine einfache Möglichkeit ist, Code oder Konfiguration wiederzuverwenden . Wenn wir in unseren Tests Vererbung verwenden, bedeutet dies, dass

  • Wenn wir sicherstellen wollen, dass nur der relevante Code für unsere Testklassen sichtbar ist, müssen wir wahrscheinlich eine "komplexe" Klassenhierarchie erstellen, weil es nicht sehr "sauber" ist, alles in eine Oberklasse zu stecken. Das macht unsere Tests sehr schwer lesbar.
  • Unsere Testklassen sind der Gnade ihrer Oberklasse(n) ausgeliefert, und jede Änderung, die wir an einer solchen Oberklasse vornehmen, kann sich auf alle Unterklassen auswirken. Das macht es "schwer", unsere Tests zu schreiben und zu warten.

Also, warum ist das wichtig? Es ist wichtig, weil Tests auch Code sind! Deshalb gilt diese Regel auch für Testcode.

Übrigens, wussten Sie, dass die Entscheidung, Vererbung in unseren Tests zu verwenden, auch praktische Konsequenzen hat?

2. Vererbung kann sich negativ auf die Leistung unserer Testsuite auswirken

Wenn wir in unseren Tests Vererbung verwenden, kann sich dies negativ auf die Leistung unserer Testsuite auswirken. Um den Grund dafür zu verstehen, müssen wir verstehen, wie JUnit mit Klassenhierarchien umgeht:

  1. Bevor JUnit die Tests einer Testklasse aufruft, sucht es nach Methoden, die mit @BeforeClass annotiert sind Anmerkung. Es durchläuft die gesamte Klassenhierarchie mithilfe von Reflektion. Nachdem es java.lang.Object erreicht hat , ruft es alle mit @BeforeClass annotierten Methoden auf Anmerkung (Eltern zuerst).
  2. Bevor JUnit eine Methode aufruft, die mit @Test annotiert ist annotation, es tut dasselbe für Methoden, die mit @Before annotiert sind Anmerkung.
  3. Nachdem JUnit den Test ausgeführt hat, sucht es nach Methoden, die mit @After annotiert sind Anmerkung und ruft alle gefundenen Methoden auf.
  4. Nachdem alle Tests einer Testklasse ausgeführt wurden, durchläuft JUnit die Klassenhierarchie erneut und sucht nach Methoden, die mit @AfterClass annotiert sind Anmerkung (und ruft diese Methoden auf).

Mit anderen Worten, wir verschwenden CPU-Zeit auf zwei Arten:

  1. Das Durchlaufen der Testklassenhierarchie ist verschwendete CPU-Zeit.
  2. Das Aufrufen der Setup- und Teardown-Methoden ist verschwendete CPU-Zeit, wenn unsere Tests sie nicht benötigen.

Sie könnten natürlich argumentieren, dass dies kein großes Problem darstellt, da es nur wenige Millisekunden pro Testfall dauert. Die Chancen stehen jedoch gut, dass Sie nicht gemessen haben, wie lange es wirklich dauert.

Oder haben Sie?

Wenn dies beispielsweise nur 2 Millisekunden pro Testfall dauert und unsere Testsuite 3000 Tests umfasst, ist unsere Testsuite 6 Sekunden langsamer als sie sein könnte. Das klingt vielleicht nicht nach einer langen Zeit, aber es fühlt sich an wie eine Ewigkeit wenn wir unsere Tests auf unserem eigenen Computer ausführen.

Es liegt in unserem besten Interesse, unsere Feedback-Schleife so schnell wie möglich zu halten, und die Verschwendung von CPU-Zeit hilft uns nicht, dieses Ziel zu erreichen.

Außerdem ist die verschwendete CPU-Zeit nicht das einzige, was unsere Feedback-Schleife verlangsamt. Wenn wir in unseren Testklassen Vererbung verwenden, müssen wir auch einen mentalen Preis zahlen.

3. Die Verwendung von Vererbung erschwert das Lesen von Tests

Die größten Vorteile automatisierter Tests sind:

  • Tests dokumentieren, wie unser Code derzeit funktioniert.
  • Tests stellen sicher, dass unser Code korrekt funktioniert.

Wir möchten unsere Tests leicht lesbar machen, weil

  • Wenn unsere Tests einfach zu lesen sind, ist es auch leicht zu verstehen, wie unser Code funktioniert.
  • Wenn unsere Tests gut lesbar sind, ist es einfach, das Problem zu finden, wenn ein Test fehlschlägt. Wenn wir ohne Verwendung des Debuggers nicht herausfinden können, was falsch ist, ist unser Test nicht klar genug.

Das ist nett, aber es erklärt nicht wirklich, warum die Verwendung von Vererbung unsere Tests schwerer lesbar macht. Ich werde anhand eines einfachen Beispiels demonstrieren, was ich meinte.

Nehmen wir an, wir müssen Komponententests für create() schreiben -Methode von TodoCrudServiceImpl Klasse. Der relevante Teil von TodoCrudServiceImpl Klasse sieht wie folgt aus:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TodoCrudServiceImpl implements TodoCrudService {

    private TodoRepository repository;
    
    @Autowired
    public TodoCrudService(TodoRepository repository) {
        this.repository = repository;
    }
        
    @Transactional
    @Overrides
    public Todo create(TodoDTO todo) {
        Todo added = Todo.getBuilder(todo.getTitle())
                .description(todo.getDescription())
                .build();
        return repository.save(added);
    }
    
    //Other methods are omitted.
}

Wenn wir mit dem Schreiben dieses Tests beginnen, erinnern wir uns an das DRY-Prinzip und beschließen, zwei abstrakte Klassen zu erstellen, die sicherstellen, dass wir dieses Prinzip nicht verletzen. Schließlich müssen wir andere Tests schreiben, nachdem wir diesen abgeschlossen haben, und es macht Sinn, so viel Code wie möglich wiederzuverwenden.

Zuerst erstellen wir den AbstractMockitoTest Klasse. Diese Klasse stellt sicher, dass alle von ihren Unterklassen gefundenen Testmethoden von MockitoJUnitRunner aufgerufen werden . Sein Quellcode sieht wie folgt aus:

import org.junit.runner.RunWith;
import org.mockito.runners.MockitoJUnitRunner;

@RunWith(MockitoJUnitRunner.class)
public abstract class AbstractMockitoTest {
}

Zweite , erstellen wir den AbstractTodoTest Klasse. Diese Klasse stellt nützliche Hilfsmethoden und Konstanten für andere Testklassen bereit, die Methoden testen, die sich auf Aufgabeneinträge beziehen. Sein Quellcode sieht wie folgt aus:

import static org.junit.Assert.assertEquals;

public abstract class AbstractTodoTest extends AbstractMockitoTest {

    protected static final Long ID = 1L;
    protected static final String DESCRIPTION = "description";
    protected static final String TITLE = "title";

    protected TodoDTO createDTO(String title, String description) {
        retun createDTO(null, title, description);
    }

    protected TodoDTO createDTO(Long id, 
                                String title, 
                                String description) {
        TodoDTO dto = new DTO();
        
        dto.setId(id);
        dto.setTitle(title);
        dto.setDescrption(description);
    
        return dto;
    }
    
    protected void assertTodo(Todo actual, 
                            Long expectedId, 
                            String expectedTitle, 
                            String expectedDescription) {
        assertEquals(expectedId, actual.getId());
        assertEquals(expectedTitle, actual.getTitle());
        assertEquals(expectedDescription, actual.getDescription());
    }
}

Jetzt können wir einen Komponententest für create() schreiben -Methode von TodoCrudServiceImpl Klasse. Der Quellcode unserer Testklasse sieht wie folgt aus:

import org.junit.Before;
import org.junit.Test;
import org.mockito.Mock;

import static org.mockito.Matchers.isA;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;

public TodoCrudServiceImplTest extends AbstractTodoTest {

    @Mock
    private TodoRepository repositoryMock;
    
    private TodoCrudServiceImpl service;
    
    @Before
    public void setUp() {
        service = new TodoCrudServiceImpl(repositoryMock);
    }
    
    @Test
    public void create_ShouldCreateNewTodoEntryAndReturnCreatedEntry() {
        TodoDTO dto = createDTO(TITLE, DESCRIPTION);
        
        when(repositoryMock.save(isA(Todo.class))).thenAnswer(new Answer<Todo>() {
            @Override
            public Todo answer(InvocationOnMock invocationOnMock) throws Throwable {
                Todo todo = (Todo) invocationOnMock.getArguments()[0];
                todo.setId(ID);
                return site;
            }
        });
                
        Todo created = service.create(dto);
        
        verify(repositoryMock, times(1)).save(isA(Todo.class));
        verifyNoMoreInteractions(repositoryMock);
                
        assertTodo(created, ID, TITLE, DESCRIPTION);
    }
}

Ist unser Einheitentest Wirklich leicht zu lesen? Das Seltsamste ist, dass es ziemlich sauber aussieht, wenn wir nur einen kurzen Blick darauf werfen. Bei näherer Betrachtung stellen sich jedoch folgende Fragen:

  • Es scheint, dass das TodoRepository ist ein Scheinobjekt. Dieser Test muss den MockitoJUnitRunner verwenden . Wo wird der Testrunner konfiguriert?
  • Der Komponententest erstellt ein neues TodoDTO Objekte durch Aufrufen von createDTO() Methode. Wo können wir diese Methode finden?
  • Der von dieser Klasse gefundene Einheitentest verwendet Konstanten. Wo werden diese Konstanten deklariert?
  • Der Komponententest bestätigt die Informationen des zurückgegebenen Todo -Objekt durch Aufrufen von assertTodo() Methode. Wo können wir diese Methode finden?

Dies mag wie "kleine" Probleme erscheinen. Trotzdem braucht es Zeit, um die Antworten auf diese Fragen herauszufinden, da wir den Quellcode des AbstractTodoTest lesen müssen und AbstractMockitoTest Klassen.

Wenn wir eine einfache Einheit wie diese nicht verstehen können, indem wir ihren Quellcode lesen, ist es ziemlich klar, dass der Versuch, komplexere Testfälle zu verstehen, sehr schmerzhaft sein wird .

Ein größeres Problem ist, dass Code wie dieser unsere Feedback-Schleife viel länger als nötig macht.

Was sollen wir tun?

Wir haben gerade drei Gründe kennengelernt, warum wir die Vererbung in unseren Tests nicht verwenden sollten. Die offensichtliche Frage ist:

Was sollten wir tun, wenn wir die Vererbung nicht zur Wiederverwendung von Code und Konfiguration verwenden sollten?

Das ist eine sehr gute Frage, die ich in einem anderen Blogbeitrag beantworten werde.


Java-Tag