Java >> Java Tutorial >  >> Java

Saubere Tests schreiben - Hüten Sie sich vor Magie

Magie ist der Erzfeind von lesbarem Code, und eine der häufigsten Formen von Magie, die in unserem Code gefunden werden kann, ist eine magische Zahl.

Magische Zahlen verunreinigen unseren Quellcode und verwandeln ihn in einen Haufen unlesbaren und nicht wartbaren Mülls.

Deshalb sollten wir magische Zahlen um jeden Preis vermeiden .

Dieser Blogbeitrag demonstriert, welche Wirkung magische Zahlen auf unsere Testfälle haben, und beschreibt, wie wir sie durch die Verwendung von Konstanten eliminieren können.

Konstanten zur Rettung

Wir verwenden Konstanten in unserem Code, weil unser Code ohne Konstanten mit magischen Zahlen übersät wäre. Die Verwendung magischer Zahlen hat zwei Konsequenzen:

  1. Unser Code ist schwer zu lesen, weil magische Zahlen nur Werte ohne Bedeutung sind.
  2. Unser Code ist schwer zu pflegen, denn wenn wir den Wert einer magischen Zahl ändern müssen, müssen wir alle Vorkommen dieser magischen Zahl finden und alle aktualisieren.

Mit anderen Worten,

  • Konstanten helfen uns, magische Zahlen durch etwas zu ersetzen, das den Grund ihrer Existenz beschreibt.
  • Konstanten erleichtern die Wartung unseres Codes, denn wenn sich der Wert einer Konstante ändert, müssen wir diese Änderung nur an einer Stelle vornehmen.

Wenn wir an die magischen Zahlen denken, die wir in unseren Testfällen gefunden haben, stellen wir fest, dass sie in zwei Gruppen eingeteilt werden können:

  1. Magische Zahlen, die für eine einzelne Testklasse relevant sind. Ein typisches Beispiel für eine solche magische Zahl ist der Eigenschaftswert eines Objekts, der in einer Testmethode erstellt wurde. Wir sollten diese Konstanten in der Testklasse deklarieren .
  2. Magische Zahlen, die für mehrere Testklassen relevant sind. Ein gutes Beispiel für diese Art von magischer Zahl ist der Inhaltstyp einer Anfrage, die von einem Spring MVC-Controller verarbeitet wird. Wir sollten diese Konstanten zu einer nicht instanziierbaren Klasse hinzufügen .

Sehen wir uns beide Situationen genauer an.

Konstanten in der Testklasse deklarieren

Warum sollten wir also einige Konstanten in unserer Testklasse deklarieren?

Wenn wir über die Vorteile der Verwendung von Konstanten nachdenken, fällt uns schließlich als Erstes ein, dass wir magische Zahlen aus unseren Tests eliminieren sollten, indem wir Klassen erstellen, die die in unseren Tests verwendeten Konstanten enthalten. Beispielsweise könnten wir eine TodoConstants erstellen Klasse, die die im TodoControllerTest verwendeten Konstanten enthält , TodoCrudServiceTest und TodoTest Klassen.

Das ist eine schlechte Idee .

Obwohl es manchmal ratsam ist, Daten auf diese Weise zu teilen, sollten wir diese Entscheidung nicht leichtfertig treffen, da unsere einzige Motivation, Konstanten in unsere Tests einzuführen, meistens darin besteht, Tippfehler und magische Zahlen zu vermeiden.

Auch wenn die magischen Zahlen nur für eine einzige Testklasse relevant sind, macht es keinen Sinn, diese Art von Abhängigkeit in unsere Tests einzuführen, nur weil wir die Anzahl der erzeugten Konstanten minimieren wollen.

Meiner Meinung nach der einfachste Weg Um mit dieser Art von Situation fertig zu werden, müssen Konstanten in der Testklasse deklariert werden.

Lassen Sie uns herausfinden, wie wir den im vorherigen Teil dieses Tutorials beschriebenen Komponententest verbessern können. Dieser Komponententest wurde geschrieben, um registerNewUserAccount() zu testen Methode des RepositoryUserService Klasse, und es überprüft, ob diese Methode ordnungsgemäß funktioniert, wenn ein neues Benutzerkonto erstellt wird, indem ein Social Sign-Anbieter und eine eindeutige E-Mail-Adresse verwendet werden.

Der Quellcode dieses Testfalls sieht wie folgt aus:

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
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 org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
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.verifyZeroInteractions;
import static org.mockito.Mockito.when;


@RunWith(MockitoJUnitRunner.class)
public class RepositoryUserServiceTest {

    private RepositoryUserService registrationService;

    @Mock
    private PasswordEncoder passwordEncoder;

    @Mock
    private UserRepository repository;

    @Before
    public void setUp() {
        registrationService = new RepositoryUserService(passwordEncoder, repository);
    }


    @Test
    public void registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldCreateNewUserAccountAndSetSignInProvider() throws DuplicateEmailException 		{
        RegistrationForm registration = new RegistrationForm();
        registration.setEmail("[email protected]");
        registration.setFirstName("John");
        registration.setLastName("Smith");
        registration.setSignInProvider(SocialMediaService.TWITTER);

        when(repository.findByEmail("[email protected]")).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);

        assertEquals("[email protected]", createdUserAccount.getEmail());
        assertEquals("John", createdUserAccount.getFirstName());
        assertEquals("Smith", createdUserAccount.getLastName());
        assertEquals(SocialMediaService.TWITTER, createdUserAccount.getSignInProvider());
        assertEquals(Role.ROLE_USER, createdUserAccount.getRole());
        assertNull(createdUserAccount.getPassword());

        verify(repository, times(1)).findByEmail("[email protected]");
        verify(repository, times(1)).save(createdUserAccount);
        verifyNoMoreInteractions(repository);
        verifyZeroInteractions(passwordEncoder);
    }
}

Das Problem ist, dass dieser Testfall magische Zahlen verwendet, wenn er ein neues RegistrationForm erstellt Objekt, konfiguriert das Verhalten des UserRepository mock, überprüft diese Informationen des zurückgegebenen Benutzers Objekt korrekt ist und überprüft, ob die richtigen Methodenmethoden des UserRepository mock werden in der getesteten Dienstmethode aufgerufen.

Nachdem wir diese magischen Zahlen entfernt haben, indem wir in unserer Testklasse Konstanten deklariert haben, sieht der Quellcode unseres Tests wie folgt aus:

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
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 org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
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.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 Role ROLE_REGISTERED_USER = Role.ROLE_USER;
    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_SocialSignInAndUniqueEmail_ShouldCreateNewUserAccountAndSetSignInProvider() throws DuplicateEmailException 		{
        RegistrationForm registration = new RegistrationForm();
        registration.setEmail(REGISTRATION_EMAIL_ADDRESS);
        registration.setFirstName(REGISTRATION_FIRST_NAME);
        registration.setLastName(REGISTRATION_LAST_NAME);
        registration.setSignInProvider(SOCIAL_SIGN_IN_PROVIDER);

        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);

        assertEquals(REGISTRATION_EMAIL_ADDRESS, createdUserAccount.getEmail());
        assertEquals(REGISTRATION_FIRST_NAME, createdUserAccount.getFirstName());
        assertEquals(REGISTRATION_LAST_NAME, createdUserAccount.getLastName());
        assertEquals(SOCIAL_SIGN_IN_PROVIDER, createdUserAccount.getSignInProvider());
        assertEquals(ROLE_REGISTERED_USER, createdUserAccount.getRole());
        assertNull(createdUserAccount.getPassword());

        verify(repository, times(1)).findByEmail(REGISTRATION_EMAIL_ADDRESS);
        verify(repository, times(1)).save(createdUserAccount);
        verifyNoMoreInteractions(repository);
        verifyZeroInteractions(passwordEncoder);
    }
}

Dieses Beispiel zeigt, dass das Deklarieren von Konstanten in der Testklasse drei Vorteile hat:

  1. Unser Testfall ist einfacher zu lesen, da die magischen Zahlen durch richtig benannte Konstanten ersetzt wurden.
  2. Unser Testfall ist einfacher zu pflegen, da wir die Werte von Konstanten ändern können, ohne Änderungen am eigentlichen Testfall vorzunehmen.
  3. Es ist einfacher, neue Tests für registerNewUserAccount() zu schreiben Methode des RepositoryUserService Klasse, weil wir Konstanten anstelle von magischen Zahlen verwenden können. Das bedeutet, dass wir uns keine Gedanken über Tippfehler machen müssen.

Manchmal verwenden unsere Tests jedoch magische Zahlen, die für mehrere Testklassen wirklich relevant sind. Lassen Sie uns herausfinden, wie wir mit dieser Situation umgehen können.

Hinzufügen von Konstanten zu einer nicht instanziierbaren Klasse

Wenn die Konstante für mehrere Testklassen relevant ist, macht es keinen Sinn, die Konstante in jeder Testklasse zu deklarieren, die sie verwendet. Schauen wir uns eine Situation an, in der es sinnvoll ist, einer nicht instanziierbaren Klasse eine Konstante hinzuzufügen.

Nehmen wir an, wir müssen zwei Unit-Tests für eine REST-API schreiben:

  • Der erste Komponententest stellt sicher, dass wir der Datenbank keinen leeren Aufgabeneintrag hinzufügen können.
  • Der zweite Komponententest stellt sicher, dass wir der Datenbank keine leere Notiz hinzufügen können.

Der Quellcode des ersten Unit-Tests sieht wie folgt aus:

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.nio.charset.Charset;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebUnitTestContext.class})
@WebAppConfiguration
public class TodoControllerTest {

    private static final MediaType APPLICATION_JSON_UTF8 = new MediaType(
            MediaType.APPLICATION_JSON.getType(), MediaType.APPLICATION_JSON.getSubtype(),
            Charset.forName("utf8")
    );

    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private WebApplicationContext webAppContext;

    @Before
    public void setUp() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();
    }

    @Test
    public void add_EmptyTodoEntry_ShouldReturnHttpRequestStatusBadRequest() throws Exception {
        TodoDTO addedTodoEntry = new TodoDTO();

        mockMvc.perform(post("/api/todo")
                .contentType(APPLICATION_JSON_UTF8)
                .content(objectMapper.writeValueAsBytes(addedTodoEntry))
        )
                .andExpect(status().isBadRequest());
    }
}

Der Quellcode des zweiten Unit-Tests sieht wie folgt aus:

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.nio.charset.Charset;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebUnitTestContext.class})
@WebAppConfiguration
public class NoteControllerTest {

    private static final MediaType APPLICATION_JSON_UTF8 = new MediaType(
            MediaType.APPLICATION_JSON.getType(), MediaType.APPLICATION_JSON.getSubtype(),
            Charset.forName("utf8")
    );

    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private WebApplicationContext webAppContext;

    @Before
    public void setUp() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();
    }

    @Test
    public void add_EmptyNote_ShouldReturnHttpRequestStatusBadRequest() throws Exception {
        NoteDTO addedNote = new NoteDTO();

        mockMvc.perform(post("/api/note")
                .contentType(APPLICATION_JSON_UTF8)
                .content(objectMapper.writeValueAsBytes(addedNote))
        )
                .andExpect(status().isBadRequest());
    }
}

Diese beiden Testklassen deklarieren eine Konstante namens APPLICATION_JSON_UTF8 . Diese Konstante gibt den Inhaltstyp und den Zeichensatz der Anfrage an. Außerdem ist klar, dass wir diese Konstante in jeder Testklasse brauchen, die Tests für unsere Controller-Methoden enthält.

Bedeutet das, dass wir diese Konstante in jeder solchen Testklasse deklarieren sollten?

Nein!

Wir sollten diese Konstante aus zwei Gründen in eine nicht instanziierbare Klasse verschieben:

  1. Es ist für mehrere Testklassen relevant.
  2. Das Verschieben in eine separate Klasse erleichtert es uns, neue Tests für unsere Controller-Methoden zu schreiben und unsere bestehenden Tests zu pflegen.

Lassen Sie uns eine abschließende WebTestConstants erstellen Klasse verschieben Sie die APPLICATION_JSON_UTF8 Konstante zu dieser Klasse hinzufügen und der erstellten Klasse einen privaten Konstruktor hinzufügen.

Der Quellcode der WebTestConstant Klasse sieht wie folgt aus:

import org.springframework.http.MediaType;

public final class WebTestConstants {
    public static final MediaType APPLICATION_JSON_UTF8 = new MediaType(
            MediaType.APPLICATION_JSON.getType(), MediaType.APPLICATION_JSON.getSubtype(), 
            Charset.forName("utf8")
    );
	
	private WebTestConstants() {
	}
}

Nachdem wir dies getan haben, können wir APPLICATION_JSON_UTF8 entfernen Konstanten aus unseren Testklassen. Der Quellcode unseres neuen Tests sieht wie folgt aus:

import com.fasterxml.jackson.databind.ObjectMapper;
import net.petrikainulainen.spring.jooq.config.WebUnitTestContext;
import net.petrikainulainen.spring.jooq.todo.dto.TodoDTO;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.nio.charset.Charset;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebUnitTestContext.class})
@WebAppConfiguration
public class TodoControllerTest {

    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private WebApplicationContext webAppContext;

    @Before
    public void setUp() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();
    }

    @Test
    public void add_EmptyTodoEntry_ShouldReturnHttpRequestStatusBadRequest() throws Exception {
        TodoDTO addedTodoEntry = new TodoDTO();

        mockMvc.perform(post("/api/todo")
                        .contentType(WebTestConstants.APPLICATION_JSON_UTF8)
                        .content(objectMapper.writeValueAsBytes(addedTodoEntry))
        )
                .andExpect(status().isBadRequest());
    }
}

Wir haben gerade doppelten Code aus unseren Testklassen entfernt und den Aufwand reduziert, der zum Schreiben neuer Tests für unsere Controller erforderlich ist. Ziemlich cool, oder?

Wenn wir den Wert einer Konstante ändern, die einer Konstantenklasse hinzugefügt wird, wirkt sich diese Änderung auf jeden Testfall aus, der diese Konstante verwendet. Deshalb sollten wir die Anzahl der Konstanten minimieren, die einer Konstantenklasse hinzugefügt werden .

Zusammenfassung

Wir haben jetzt gelernt, dass Konstanten uns helfen können, saubere Tests zu schreiben und den Aufwand zu reduzieren, der erforderlich ist, um neue Tests zu schreiben und unsere bestehenden Tests zu pflegen. Es gibt ein paar Dinge, an die wir denken sollten, wenn wir die in diesem Blogbeitrag gegebenen Ratschläge in die Praxis umsetzen:

  • Wir müssen Konstanten und Konstantenklassen gute Namen geben. Wenn wir das nicht tun, nutzen wir nicht das volle Potenzial dieser Techniken.
  • Wir sollten keine neuen Konstanten einführen, ohne herauszufinden, was wir mit dieser Konstante erreichen wollen. Die Realität ist oft viel komplexer als die Beispiele dieses Blogbeitrags. Wenn wir Code auf Autopilot schreiben, ist die Wahrscheinlichkeit groß, dass wir die beste Lösung für das vorliegende Problem verpassen.

Java-Tag