Java >> Java Tutorial >  >> Tag >> Spring

Frühling aus den Schützengräben:Bereinigen unseres Testcodes mit HTTP-Request-Buildern

Das Spring MVC Test Framework hilft uns, saubere Unit- und Integrationstests für unsere Spring MVC-Controller zu schreiben. Ich bin ein großer Fan des Spring MVC Test Frameworks und denke gerne, dass ich saubere Tests schreibe.

Vor einigen Monaten erwähnte mein Kollege jedoch, dass meine Tests eine Menge doppelten Code zu enthalten scheinen. Ich war ein bisschen genervt von seiner Bemerkung (verdammtes Ego), aber ich musste zugeben, dass er recht hatte.

Dieser Blogbeitrag beschreibt, wie wir unser Problem gelöst haben.

Das Problem

Das Problem war, dass jede Testmethode eine eigene Kopie des Codes hatte, der die HTTP-Anforderung erstellt und an die getestete Controller-Methode sendet. Werfen wir einen Blick auf einige Einheiten- und Integrationstests, die dieses Problem demonstrieren.

Zuerst , der TaskCrudControllerTest -Klasse enthält zwei Unit-Tests für create() Methode. Sein Quellcode sieht wie folgt aus (der doppelte Code ist hervorgehoben):

@RunWith(HierarchicalContextRunner.class)
@Category(UnitTest.class)
public class TaskCrudControllerTest {

    private TaskCrudService crudService;
    private MockMvc mockMvc;

    @Before
    public void configureSystemUnderTest() {
        crudService = mock(TaskCrudService.class);

        mockMvc = MockMvcBuilders.standaloneSetup(new TaskCrudController(crudService))
                .setControllerAdvice(new TaskErrorHandler())
                .setLocaleResolver(fixedLocaleResolver())
                .setMessageConverters(objectMapperHttpMessageConverter())
                .build();
    }

    public class Create {

        private TaskFormDTO input;

        public class WhenAllFieldsAreValid {

            @Before
            public void configureTestCases() {
                input = createValidInput();
                returnCreatedTask();
            }

            private TaskFormDTO createValidInput() {
                TaskFormDTO input = new TaskFormDTO();
                input.setTitle("title");
                input.setDescription("description");
                return input;
            }

            private void returnCreatedTask() {
                //This returns the created task. Omitted because of clarity.
            }

            @Test
            public void shouldReturnHttpStatusCodeCreated() throws Exception {
                mockMvc.perform(post("/api/task")
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(WebTestUtil.convertObjectToJsonBytes(input))
                )
                        .andExpect(status().isCreated());
            }

            @Test
            public void shouldReturnCreatedTaskWithJson() throws Exception {
                mockMvc.perform(post("/api/task")
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(WebTestUtil.convertObjectToJsonBytes(input))
                )
                        .andExpect(
                                content().contentType(MediaType.APPLICATION_JSON_UTF8)
                        );
            }

        }
    }
}

Zweiter , die CreateTaskAsUserWhenValidationIsSuccessful Klasse enthält zwei Integrationstests für create() Methode. Sein Quellcode sieht wie folgt aus (der doppelte Code ist hervorgehoben):

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {IntegrationTestContext.class})
@WebAppConfiguration
@TestExecutionListeners({
        DependencyInjectionTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        DbUnitTestExecutionListener.class,
        ServletTestExecutionListener.class,
        WithSecurityContextTestExecutionListener.class
})
@DatabaseSetup({
        "/com/testwithspring/intermediate/users.xml",
        "/com/testwithspring/intermediate/no-tasks-and-tags.xml"
})
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader.class)
@Category(IntegrationTest.class)
@ActiveProfiles(Profiles.INTEGRATION_TEST)
public class CreateTaskAsUserWhenValidationIsSuccessful {

    @Autowired
    IdColumnReset idColumnReset;

    @Autowired
    private WebApplicationContext webAppContext;

    private MockMvc mockMvc;

    private TaskFormDTO input;

    @Before
    public void configureSystemUnderTest() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
                .apply(springSecurity())
                .build();

        idColumnReset.resetIdColumns("tasks");

        input = createValidInput();
    }

    private TaskFormDTO createValidInput() {
        TaskFormDTO input = new TaskFormDTO();
        input.setDescription(Tasks.WriteExampleApp.DESCRIPTION);
        input.setTitle(Tasks.WriteExampleApp.TITLE);
        return input;
    }

    @Test
    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
    public void shouldReturnHttpStatusCodeCreated() throws Exception {
        mockMvc.perform(post("/api/task")
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(WebTestUtil.convertObjectToJsonBytes(input))
                .with(csrf())
        )
                .andExpect(status().isCreated());
    }

    @Test
    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
    public void shouldReturnCreatedTaskAsJson() throws Exception {
        mockMvc.perform(post("/api/task")
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(WebTestUtil.convertObjectToJsonBytes(input))
                .with(csrf())
        )
                .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8));
    }
}

Wie wir sehen können, obwohl unsere Tests relativ einfach sind, ist jeder Test:

  • Konfiguriert die verwendete HTTP-Anfragemethode und die Ziel-URL.
  • Legt den Inhaltstyp der HTTP-Anfrage fest.
  • Legt den Anfragetext der HTTP-Anfrage fest.

Außerdem stellen unsere Integrationstests sicher, dass die HTTP-Anfrage ein gültiges CSRF-Token enthält. Mit anderen Worten, unsere Tests sind recht einfach zu lesen, aber der doppelte Code verursacht zwei weitere Probleme:

  • Unsere Tests sind schwer zu schreiben, weil es langweilig ist und "viel Zeit" in Anspruch nimmt, immer wieder denselben Code zu schreiben. Vielleicht neigen deshalb so viele Leute dazu, sich wiederholenden Code mit Copy-and-Paste-Programmierung zu schreiben.
  • Unsere Tests sind schwer zu pflegen, denn wenn wir Änderungen an der getesteten Controller-Methode vornehmen, müssen wir die gleichen Änderungen an jeder Testmethode vornehmen, die die geänderte Controller-Methode testet.

Lassen Sie uns herausfinden, wie wir diese Probleme lösen können.

HTTP Request Builders zur Rettung

Wenn wir doppelten Code aus unserer Testsuite entfernen wollen, müssen wir einen HTTP-Request-Builder erstellen, der HTTP-Requests erstellt und sie an die getesteten Controller-Methoden sendet. Wir können unseren HTTP-Request-Builder erstellen, indem wir diesen Schritten folgen:

Zuerst , müssen wir unsere HTTP-Request-Builder-Klasse erstellen, indem wir diesen Schritten folgen:

  1. Erstellen Sie eine Öffentlichkeit und final Klasse namens:TaskHttpRequestBuilder .
  2. Fügen Sie einen MockMvc hinzu Feld zur erstellten Klasse.
  3. Schreiben Sie einen Konstruktor, der einen MockMvc erhält Objekt als Konstruktorargument und setzt einen Verweis auf dieses Objekt auf MockMvc Feld.

Nachdem wir unsere HTTP-Request-Builder-Klasse erstellt haben, sieht ihr Quellcode wie folgt aus:

import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

public final class TaskHttpRequestBuilder {

    private final MockMvc mockMvc;

    public TaskHttpRequestBuilder(MockMvc mockMvc) {
        this.mockMvc = mockMvc;
    }
}

Zweiter , müssen wir die Methode schreiben, die eine HTTP-Anforderung erstellt und an die getestete Controller-Methode sendet. Diese Methode heißt createTask() , und es wird ein TaskFormDTO benötigt Objekt als Methodenparameter und gibt ein ResultActions zurück Objekt.

Wir können diese Methode implementieren, indem wir diesen Schritten folgen:

  1. Senden Sie einen POST Anfrage an den Pfad:'/api/task'.
  2. Setzen Sie den Inhaltstyp der HTTP-Anforderung auf:'application/json;charset=UTF-8'.
  3. Transformieren Sie das TaskFormDTO -Objekt in JSON-Bytes und fügen Sie die erstellten JSON-Bytes dem Anforderungstext hinzu.
  4. Stellen Sie sicher, dass die HTTP-Anfrage ein gültiges CSRF-Token hat. Wir müssen dies tun, da unsere Anwendung den von Spring Security bereitgestellten CSRF-Schutz verwendet und wir der HTTP-Anforderung ein gültiges CSRF-Token hinzufügen müssen, wenn wir Integrationstests für unsere Controller-Methode schreiben.

Nachdem wir die createTask() implementiert haben Methode, der Quellcode des TaskHttpRequestBuilder Klasse sieht wie folgt aus:

import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

public class TaskHttpRequestBuilder {

    private final MockMvc mockMvc;

    public TaskHttpRequestBuilder(MockMvc mockMvc) {
        this.mockMvc = mockMvc;
    }

    public ResultActions createTask(TaskFormDTO input) throws Exception {
        return mockMvc.perform(post("/api/task")
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(WebTestUtil.convertObjectToJsonBytes(input))
                .with(csrf())
        );
    }
}

Als Nächstes werden wir unsere Einheiten- und Integrationstests ändern, um unsere neue HTTP-Request-Builder-Klasse zu verwenden.

Ändern unserer Einheiten- und Integrationstests

Wenn wir unsere neue HTTP-Request-Builder-Klasse verwenden möchten, müssen wir MockMvc ersetzen Felder, die von unseren Testklassen mit TaskHttpRequestBuilder gefunden wurden Felder und stellen Sie sicher, dass unsere Testmethoden unsere HTTP-Request-Builder-Klasse verwenden, wenn sie HTTP-Requests an die getestete Controller-Methode senden.

Nachdem wir die erforderlichen Änderungen an unserer Unit-Test-Klasse vorgenommen haben, sieht ihr Quellcode wie folgt aus:

@RunWith(HierarchicalContextRunner.class)
@Category(UnitTest.class)
public class TaskCrudControllerTest {

    private TaskCrudService crudService;
    private TaskHttpRequestBuilder requestBuilder;

    @Before
    public void configureSystemUnderTest() {
        crudService = mock(TaskCrudService.class);

        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TaskCrudController(crudService))
                .setControllerAdvice(new TaskErrorHandler())
                .setLocaleResolver(fixedLocaleResolver())
                .setMessageConverters(objectMapperHttpMessageConverter())
                .build();
        requestBuilder = new TaskHttpRequestBuilder(mockMvc);
    }

    public class Create {

        private TaskFormDTO input;

        public class WhenAllFieldsAreValid {

            @Before
            public void configureTestCases() {
                input = createValidInput();
                returnCreatedTask();
            }

            private TaskFormDTO createValidInput() {
                TaskFormDTO input = new TaskFormDTO();
                input.setTitle("title");
                input.setDescription("description");
                return input;
            }

            private void returnCreatedTask() {
                //This returns the created task. Omitted because of clarity.
            }

            @Test
            public void shouldReturnHttpStatusCodeCreated() throws Exception {
                requestBuilder.createTask(input)
                        .andExpect(status().isCreated());
            }

            @Test
            public void shouldReturnCreatedTaskWithJson() throws Exception {
                requestBuilder.createTask(input)
                        .andExpect(
                                content().contentType(MediaType.APPLICATION_JSON_UTF8)
                        );
            }

        }
    }
}

Nachdem wir die erforderlichen Änderungen an unserer Integrationstestklasse vorgenommen haben, sieht ihr Quellcode wie folgt aus:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {IntegrationTestContext.class})
@WebAppConfiguration
@TestExecutionListeners({
        DependencyInjectionTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        DbUnitTestExecutionListener.class,
        ServletTestExecutionListener.class,
        WithSecurityContextTestExecutionListener.class
})
@DatabaseSetup({
        "/com/testwithspring/intermediate/users.xml",
        "/com/testwithspring/intermediate/no-tasks-and-tags.xml"
})
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader.class)
@Category(IntegrationTest.class)
@ActiveProfiles(Profiles.INTEGRATION_TEST)
public class CreateTaskAsUserWhenValidationIsSuccessful {

    @Autowired
    IdColumnReset idColumnReset;

    @Autowired
    private WebApplicationContext webAppContext;

    private TaskFormDTO input;

    private TaskHttpRequestBuilder requestBuilder;

    @Before
    public void configureSystemUnderTest() {
        idColumnReset.resetIdColumns("tasks");

        input = createValidInput();

        MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
                .apply(springSecurity())
                .build();
        requestBuilder = new TaskHttpRequestBuilder(mockMvc);
    }

    private TaskFormDTO createValidInput() {
        TaskFormDTO input = new TaskFormDTO();
        input.setDescription(Tasks.WriteExampleApp.DESCRIPTION);
        input.setTitle(Tasks.WriteExampleApp.TITLE);
        return input;
    }

    @Test
    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
    public void shouldReturnHttpStatusCodeCreated() throws Exception {
        requestBuilder.createTask(input)
                .andExpect(status().isCreated());
    }

    @Test
    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
    public void shouldReturnCreatedTaskAsJson() throws Exception {
        requestBuilder.createTask(input)
                .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8));
    }
}

Wie wir sehen können, haben unsere Testklassen keinen doppelten Code mehr. Lassen Sie uns die Vor- und Nachteile dieser Technik bewerten.

Die Vor- und Nachteile von HTTP Request Buildern

HTTP-Request-Builder helfen uns, die HTTP-Request-Erstellungslogik an einem Ort zu platzieren. Das bedeutet:

  • Unsere Tests sind einfacher und schneller zu schreiben, weil wir nicht immer wieder den gleichen Code schreiben müssen.
  • Unsere Tests sind einfacher zu warten. Wenn wir Änderungen an der getesteten Controller-Methode vornehmen, müssen wir diese Änderungen nur an unserer HTTP-Request-Builder-Klasse vornehmen.

Davon abgesehen hat diese Technik zwei Nachteile:

Zuerst , unsere Tests sind nicht mehr so ​​einfach zu lesen wie zuvor. Das Problem ist, dass wir, wenn wir herausfinden wollen, was für ein HTTP-Request an die getestete Controller-Methode gesendet wird, den Quellcode unserer HTTP-Request-Builder-Klasse lesen müssen. Dies verursacht einen mentalen Kontextwechsel, der ziemlich teuer sein kann.

Zweiter , müssen wir möglicherweise eine Konfiguration verwenden, die für unsere Komponententests nicht erforderlich ist, da sowohl Komponenten- als auch Integrationstests dieselbe Methode unserer HTTP-Request-Builder-Klasse verwenden. Dies kann etwas verwirrend sein. Deshalb denke ich, dass wir der Dokumentation unserer HTTP-Request-Builder-Klassen besondere Aufmerksamkeit widmen sollten.

Es ist jedoch auch möglich, dass die unnötige Konfiguration unsere Komponententests unterbricht. In diesem Fall können wir natürlich nicht die gleiche Methode in unseren Unit- und Integrationstests verwenden.

Fassen wir zusammen, was wir aus diesem Blogbeitrag gelernt haben.

Zusammenfassung

Dieser Blogbeitrag hat uns fünf Dinge beigebracht:

  • Wir können doppelten Code aus unserer Testsuite entfernen, indem wir HTTP-Request-Builder-Klassen verwenden.
  • Wenn wir HTTP-Request-Builder verwenden, sind unsere Tests einfacher zu schreiben und zu warten, da die HTTP-Request-Erstellungslogik an einem Ort gefunden wird.
  • HTTP-Request-Builder machen unsere Tests etwas schwerer lesbar, da die HTTP-Request-Erstellungslogik nicht von unseren Testmethoden gefunden wird.
  • Wenn unsere Einheiten- und Integrationstests denselben HTTP-Request-Builder verwenden, müssen wir möglicherweise eine Konfiguration verwenden, die für unsere Einheitentests nicht erforderlich ist. Dies kann verwirrend sein oder unsere Komponententests beeinträchtigen.
  • Wir sollten HTTP-Request-Builder verwenden, aber wir sollten auch die Nachteile dieser Technik verstehen und sie nur dann verwenden, wenn dies sinnvoll ist.

Java-Tag