Java >> Java Tutorial >  >> Java

Saubere Tests schreiben - Ärger im Paradies

Wenn unser Code offensichtliche Fehler aufweist, sind wir sehr motiviert, ihn zu verbessern. Irgendwann entscheiden wir jedoch, dass unser Code "gut genug" ist, und machen weiter.

Typischerweise geschieht dies, wenn wir der Meinung sind, dass die Vorteile der Verbesserung unseres vorhandenen Codes geringer sind als die erforderliche Arbeit. Wenn wir unsere Kapitalrendite unterschätzen, können wir natürlich die falsche Entscheidung treffen und es kann uns schaden.

Das ist mir passiert, und ich habe beschlossen, darüber zu schreiben, damit Sie nicht denselben Fehler machen.

„Gute“ Komponententests schreiben

Wenn wir „gute“ Unit-Tests schreiben wollen, müssen wir Unit-Tests schreiben, die:

  • Testen Sie nur eine Sache . Ein guter Komponententest kann nur aus einem Grund fehlschlagen und nur eine Sache behaupten.
  • Richtig benannt sind . Der Name der Testmethode muss angeben, was schief gelaufen ist, wenn der Test fehlschlägt.
  • Mock externe Abhängigkeiten (und Status) . Wenn ein Unit-Test fehlschlägt, wissen wir genau, wo das Problem liegt.

Wenn wir Unit-Tests schreiben, die diese Bedingungen erfüllen, schreiben wir gute Unit-Tests. Recht?

Früher dachte ich das. Jetzt bezweifle ich es .

Der Weg zur Hölle ist mit guten Vorsätzen gepflastert

Ich habe noch nie einen Softwareentwickler getroffen, der beschlossen hat, beschissene Unit-Tests zu schreiben. Wenn ein Entwickler Unit-Tests schreibt, ist es viel wahrscheinlicher, dass er/sie gute Unit-Tests schreiben möchte. Dies bedeutet jedoch nicht, dass die von diesem Entwickler geschriebenen Komponententests gut sind.

Ich wollte Komponententests schreiben, die sowohl einfach zu lesen als auch zu warten sind. Ich habe sogar ein Tutorial geschrieben, das beschreibt, wie wir saubere Tests schreiben können. Das Problem ist, dass die Ratschläge in diesem Tutorial (noch) nicht gut genug sind. Es hilft uns beim Einstieg, zeigt uns aber nicht, wie tief der Kaninchenbau wirklich ist.

Der Ansatz, der in meinem Tutorial beschrieben wird, hat zwei Hauptprobleme:

Namensstandards FTW?

Wenn wir den von Roy Osherove eingeführten "Namensstandard" verwenden, stellen wir fest, dass es überraschend schwierig ist, den Testzustand und das erwartete Verhalten zu beschreiben.

Dieser Benennungsstandard funktioniert sehr gut, wenn wir Tests für einfache Szenarien schreiben. Das Problem ist, dass echte Software nicht einfach ist. Normalerweise benennen wir unsere Testmethoden mit einer dieser beiden Optionen:

Zuerst , wenn wir versuchen, so genau wie möglich zu sein, werden die Methodennamen unserer Testmethoden viel zu laaaaaang. Am Ende müssen wir zugeben, dass wir nicht so genau werden können, wie wir es gerne hätten, weil die Methodennamen zu viel Platz einnehmen würden.

Zweiter , wenn wir versuchen, die Methodennamen so kurz wie möglich zu halten, werden die Methodennamen den getesteten Zustand und das erwartete Verhalten nicht wirklich beschreiben.

Es spielt keine Rolle, welche Option wir wählen, da wir sowieso auf das folgende Problem stoßen werden:

  • Wenn ein Test fehlschlägt, beschreibt der Methodenname nicht unbedingt, dass etwas schief gelaufen ist. Wir können dieses Problem lösen, indem wir benutzerdefinierte Behauptungen verwenden, aber sie sind nicht kostenlos.
  • Es ist schwierig, sich einen kurzen Überblick über die Szenarien zu verschaffen, die von unseren Tests abgedeckt werden.

Hier sind die Namen der Testmethoden, die wir während des Tutorials Schreiben sauberer Tests geschrieben haben:

  • registerNewUserAccount_SocialSignInAndDuplicateEmail_ShouldThrowException()
  • registerNewUserAccount_SocialSignInAndDuplicateEmail_ShouldNotSaveNewUserAccount()
  • registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldShouldSaveNewUserAccountAndSetSignInProvider()
  • registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldReturnCreatedUserAccount()
  • registerNewUserAccount_SocialSignInAnUniqueEmail_ShouldNotCreateEncodedPasswordForUser()

Diese Methodennamen sind nicht sehr lang, aber wir müssen bedenken, dass diese Komponententests geschrieben wurden, um eine einfache Registrierungsmethode zu testen. Als ich diese Namenskonvention zum Schreiben automatisierter Tests für ein echtes Softwareprojekt verwendet habe, waren die längsten Methodennamen doppelt so lang wie unser längstes Beispiel.

Das ist nicht sehr sauber oder lesbar. Wir können viel besser werden .

Es gibt keine gemeinsame Konfiguration

Wir haben unsere Unit-Tests in diesem Tutorial viel besser gemacht. Trotzdem leiden sie immer noch unter der Tatsache, dass es keinen "natürlichen" Weg gibt, die Konfiguration zwischen verschiedenen Unit-Tests zu teilen.

Das bedeutet, dass unsere Unit-Tests viel doppelten Code enthalten, der unsere Mock-Objekte konfiguriert und andere Objekte erstellt, die in unseren Unit-Tests verwendet werden.

Da es auch keine „natürliche“ Möglichkeit gibt, anzuzeigen, dass einige Konstanten nur für bestimmte Testmethoden relevant sind, müssen wir alle Konstanten am Anfang der Testklasse hinzufügen.

Der Quellcode unserer Testklasse sieht wie folgt aus (der problematische Code ist hervorgehoben):

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.runners.MockitoJUnitRunner;
import org.mockito.stubbing.Answer;
import org.springframework.security.crypto.password.PasswordEncoder;
 
import static com.googlecode.catchexception.CatchException.catchException;
import static com.googlecode.catchexception.CatchException.caughtException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Matchers.isA;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when;
 
@RunWith(MockitoJUnitRunner.class)
public class RepositoryUserServiceTest {
 
    private static final String REGISTRATION_EMAIL_ADDRESS = "[email protected]";
    private static final String REGISTRATION_FIRST_NAME = "John";
    private static final String REGISTRATION_LAST_NAME = "Smith";
    private static final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER;
 
    private RepositoryUserService registrationService;
 
    @Mock
    private PasswordEncoder passwordEncoder;
 
    @Mock
    private UserRepository repository;
 
    @Before
    public void setUp() {
        registrationService = new RepositoryUserService(passwordEncoder, repository);
    }
 
    @Test
    public void registerNewUserAccount_SocialSignInAndDuplicateEmail_ShouldThrowException() throws DuplicateEmailException {
        RegistrationForm registration = new RegistrationFormBuilder()
                .email(REGISTRATION_EMAIL_ADDRESS)
                .firstName(REGISTRATION_FIRST_NAME)
                .lastName(REGISTRATION_LAST_NAME)
                .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
                .build();
 
        when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(new User());
 
        catchException(registrationService).registerNewUserAccount(registration);
 
        assertThat(caughtException()).isExactlyInstanceOf(DuplicateEmailException.class);
    }
 
    @Test
    public void registerNewUserAccount_SocialSignInAndDuplicateEmail_ShouldNotSaveNewUserAccount() throws DuplicateEmailException {
        RegistrationForm registration = new RegistrationFormBuilder()
                .email(REGISTRATION_EMAIL_ADDRESS)
                .firstName(REGISTRATION_FIRST_NAME)
                .lastName(REGISTRATION_LAST_NAME)
                .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
                .build();
 
        when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(new User());
 
        catchException(registrationService).registerNewUserAccount(registration);
 
        verify(repository, never()).save(isA(User.class));
    }
 
    @Test
    public void registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldSaveNewUserAccountAndSetSignInProvider() throws DuplicateEmailException {
        RegistrationForm registration = new RegistrationFormBuilder()
                .email(REGISTRATION_EMAIL_ADDRESS)
                .firstName(REGISTRATION_FIRST_NAME)
                .lastName(REGISTRATION_LAST_NAME)
                .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
                .build();
 
        when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
 
        registrationService.registerNewUserAccount(registration);
 
        ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
        verify(repository, times(1)).save(userAccountArgument.capture());
 
        User createdUserAccount = userAccountArgument.getValue();
 
        assertThatUser(createdUserAccount)
                .hasEmail(REGISTRATION_EMAIL_ADDRESS)
                .hasFirstName(REGISTRATION_FIRST_NAME)
                .hasLastName(REGISTRATION_LAST_NAME)
                .isRegisteredUser()
                .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);
    }
 
 
    @Test
    public void registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldReturnCreatedUserAccount() throws DuplicateEmailException {
        RegistrationForm registration = new RegistrationFormBuilder()
                .email(REGISTRATION_EMAIL_ADDRESS)
                .firstName(REGISTRATION_FIRST_NAME)
                .lastName(REGISTRATION_LAST_NAME)
                .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
                .build();
 
        when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
 
        when(repository.save(isA(User.class))).thenAnswer(new Answer<User>() {
            @Override
            public User answer(InvocationOnMock invocation) throws Throwable {
                Object[] arguments = invocation.getArguments();
                return (User) arguments[0];
            }
        });
 
        User createdUserAccount = registrationService.registerNewUserAccount(registration);
 
        assertThatUser(createdUserAccount)
                .hasEmail(REGISTRATION_EMAIL_ADDRESS)
                .hasFirstName(REGISTRATION_FIRST_NAME)
                .hasLastName(REGISTRATION_LAST_NAME)
                .isRegisteredUser()
                .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);
    }
 
    @Test
    public void registerNewUserAccount_SocialSignInAnUniqueEmail_ShouldNotCreateEncodedPasswordForUser() throws DuplicateEmailException {
        RegistrationForm registration = new RegistrationFormBuilder()
                .email(REGISTRATION_EMAIL_ADDRESS)
                .firstName(REGISTRATION_FIRST_NAME)
                .lastName(REGISTRATION_LAST_NAME)
                .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
                .build();
 
        when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
 
        registrationService.registerNewUserAccount(registration);
 
        verifyZeroInteractions(passwordEncoder);
    }
}

Einige Entwickler würden behaupten, dass Komponententests, die wie im obigen Beispiel aussehen, sauber genug sind. Ich verstehe dieses Gefühl, weil ich früher einer von ihnen war. Diese Unit-Tests haben jedoch drei Probleme:

  1. Der Kern des Falls ist nicht so klar, wie es sein könnte . Da sich jede Testmethode selbst konfiguriert, bevor sie die getestete Methode aufruft und das erwartete Ergebnis überprüft, werden unsere Testmethoden länger als nötig. Das bedeutet, dass wir nicht einfach einen kurzen Blick auf eine zufällige Testmethode werfen und herausfinden können, was sie testet.
  2. Das Schreiben neuer Einheitentests ist langsam . Da sich jeder Komponententest selbst konfigurieren muss, ist das Hinzufügen neuer Komponententests zu unserer Testsuite viel langsamer, als es sein könnte. Ein weiterer „unerwarteter“ Nachteil ist, dass diese Art von Unit-Tests die Leute dazu anregt, das Programmieren mit Kopieren und Einfügen zu üben.
  3. Das Pflegen dieser Unit-Tests ist nervig . Bei jedem Unit-Test müssen wir Änderungen vornehmen, wenn wir dem Registrierungsformular ein neues Pflichtfeld hinzufügen oder die Implementierung des registerNewUserAccount() ändern Methode. Diese Unit-Tests sind viel zu spröde.

Mit anderen Worten, diese Komponententests sind schwer zu lesen, schwer zu schreiben und schwer zu warten. Wir müssen einen besseren Job machen .

Zusammenfassung

Dieser Blogbeitrag hat uns vier Dinge gelehrt:

  • Obwohl wir denken, dass wir gute Komponententests schreiben, ist das nicht unbedingt wahr.
  • Wenn das Ändern bestehender Funktionen langsam ist, weil wir viele Unit-Tests ändern müssen, schreiben wir keine guten Unit-Tests.
  • Wenn das Hinzufügen neuer Funktionen langsam ist, weil wir unseren Unit-Tests so viel doppelten Code hinzufügen müssen, schreiben wir keine guten Unit-Tests.
  • Wenn wir nicht sehen können, welche Situationen von unseren Unit-Tests abgedeckt werden, schreiben wir keine guten Unit-Tests.

Der nächste Teil dieses Tutorials beantwortet diese sehr relevante Frage:

Wenn unsere bestehenden Unit-Tests schlecht sind, wie können wir sie beheben?


Java-Tag