Java >> Java Tutorial >  >> Java

Saubere Tests schreiben – das beginnt bei der Konfiguration

Das erste, was wir tun müssen, wenn wir mit dem Schreiben von Unit- oder Integrationstests beginnen, ist die Konfiguration unserer Testklassen.

Wenn wir saubere Tests schreiben wollen, müssen wir unsere Testklassen sauber und einfach konfigurieren. Das scheint offensichtlich, oder?

Leider ignorieren einige Entwickler diesen Ansatz zugunsten des DRY-Prinzips (Don't Repeat Yourself).

Das ist ein GROSSER Fehler .

Dieser Blogbeitrag identifiziert die Probleme, die durch das DRY-Prinzip verursacht werden, und hilft uns, diese Probleme zu lösen.

Doppelter Code ist eine schlechte Sache

Nehmen wir an, wir müssen „Einheitentests“ für Spring MVC-Controller schreiben, indem wir das Spring MVC Test-Framework verwenden. Wir beginnen mit dem Schreiben von Komponententests für den TodoController Klasse. Allerdings müssen wir auch Unit-Tests für die anderen Controller unserer Anwendung schreiben.

Als Entwickler wissen wir, dass doppelter Code eine schlechte Sache ist. Wenn wir Code schreiben, folgen wir dem Don't Repeat Yourself (DRY)-Prinzip, das Folgendes besagt:

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

Ich vermute, dass dies einer der Gründe ist, warum Entwickler häufig Vererbung in ihrer Testsuite verwenden. Sie sehen in der Vererbung eine kostengünstige und einfache Möglichkeit, Code und Konfiguration wiederzuverwenden. Aus diesem Grund legen sie den gesamten gemeinsamen Code und die gesamte Konfiguration in die Basisklasse (oder -klassen) der eigentlichen Testklassen.

Sehen wir uns an, wie wir unsere Komponententests mit diesem Ansatz konfigurieren können.

Eine abstrakte Klasse zur Rettung

Zuerst müssen wir eine abstrakte Basisklasse erstellen, die das Spring MVC Test Framework mithilfe der eigenständigen Konfiguration konfiguriert und erwartet, dass ihre Unterklassen den getTestedController() implementieren Methode, die das getestete Controller-Objekt zurückgibt.

Der Quellcode des AbstractControllerTest Klasse sieht wie folgt aus:

import org.junit.Before;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

public abstract class AbstractControllerTest {

    private MockMvc mockMvc;

    @Before
    public void setUp() {
        mockMvc = MockMvcBuilders.standaloneSetup(getTestedController())
			.build();
    }
	
	protected MockMvc getMockMvc() {
		return mockMvc;
	}
	
	protected abstract Object getTestedController();
}

Zweite , müssen wir die eigentliche Testklasse implementieren, die das erforderliche Mock-Objekt und einen neuen TodoController erstellt Objekt. Der Quellcode des TodoControllerTest Klasse sieht wie folgt aus:

import static org.mockito.Mockito.mock;

public TodoControllerTest extends AbstractControllerTest {

    private TodoService service;

	@Override
	protected Object getTestedController() {
		service = mock(TodoService.class);
		return new TodoController(service);
	}
}

Diese Testklasse sieht ziemlich sauber aus, hat aber zwei Hauptfehler:

Zuerst , Wir können die Konfiguration unserer Testklasse nicht verstehen, ohne den Quellcode von TodoControllerTest zu lesen und AbstractControllerTest Klassen.

Dies mag wie ein kleines Problem erscheinen, aber es bedeutet, dass wir unsere Aufmerksamkeit von der Testklasse auf die Basisklasse (oder -klassen) lenken müssen. Dies erfordert einen mentalen Kontextwechsel, und Kontextwechsel ist SEHR teuer .

Sie können argumentieren, dass der mentale Preis für die Verwendung von Vererbung (in diesem Fall) ziemlich niedrig ist, weil die Konfiguration so einfach ist. Das stimmt, aber es ist gut, sich daran zu erinnern, dass reale Anwendungen oft eine komplexere Konfiguration erfordern.

Die tatsächlichen Kosten des Kontextwechsels hängen von der Tiefe der Testklassenhierarchie und der Komplexität unserer Konfiguration ab.

Zweite , Wir können keine unterschiedliche Konfiguration für verschiedene Testklassen verwenden. Ein typisches Szenario ist beispielsweise, dass unsere Webanwendung sowohl normale Controller als auch REST-Controller hat.

Wir könnten den erstellten MockMvc konfigurieren Objekt, beide Controller zu unterstützen, aber das ist keine gute Idee, weil es unsere Konfiguration komplexer macht, als sie sein sollte. Wenn also ein Testfall fehlschlägt, kann es sehr schwierig sein, herauszufinden, ob er aufgrund eines Fehlers fehlgeschlagen ist oder weil unsere Konfiguration nicht korrekt ist.

Außerdem denke ich, dass dies gegen die Grundidee von Komponententests verstößt, unsere Tests in einer Umgebung auszuführen, die nur den für unsere Tests relevanten Code enthält. Wenn wir beispielsweise Komponententests für einen REST-Controller schreiben, benötigen wir keinen ViewResolver oder ein SimpleMappingExceptionResolver . Wenn wir jedoch Unit-Tests für normale Controller schreiben, brauchen wir diese Komponenten, aber wir brauchen keinen MappingJackson2HttpMessageConverter oder ein ExceptionHandlerExceptionResolver .

Wie chaotisch darf es sein? Nun, ich habe eine abstrakte Basisklasse erstellt, die einen MockMvc erstellt -Objekt, das sowohl normale Controller als auch REST-Controller unterstützt. Sein Quellcode sieht wie folgt aus:

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JSR310Module;
import org.junit.Before;
import org.springframework.context.support.StaticMessageSource;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.handler.SimpleMappingExceptionResolver;
import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver;
import org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod;
import org.springframework.web.servlet.view.InternalResourceViewResolver;
import org.springframework.web.servlet.view.JstlView;

public abstract class AbstractControllerTest {

    private MockMvc mockMvc;

    @Before
    public void setUp() {
		StaticMessageSource messageSource = new StaticMessageSource();
		messageSource.setUseCodeAsDefaultMessage(true);	

        mockMvc = MockMvcBuilders.standaloneSetup(getTestedController())
			.setHandlerExceptionResolvers(exceptionResolver(), restErrorHandler(messageSource))
			.setMessageConverters(jacksonDateTimeConverter())
            .setValidator(validator())
            .setViewResolvers(viewResolver())
			.build();
    }
	
	protected MockMvc getMockMvc() {
		return mockMvc;
	}
	
	protected abstract Object getTestedController();
	
	private HandlerExceptionResolver exceptionResolver() {
		SimpleMappingExceptionResolver exceptionResolver = new SimpleMappingExceptionResolver();

		Properties exceptionMappings = new Properties();	

		exceptionMappings.put(
			"net.petrikainulainen.spring.testmvc.todo.exception.TodoNotFoundException", 
			"error/404"
		);
		exceptionMappings.put("java.lang.Exception", "error/error");
		exceptionMappings.put("java.lang.RuntimeException", "error/error");

		exceptionResolver.setExceptionMappings(exceptionMappings);

		Properties statusCodes = new Properties();	

		statusCodes.put("error/404", "404");
		statusCodes.put("error/error", "500");

		exceptionResolver.setStatusCodes(statusCodes);

		return exceptionResolver;
	}
	
	private MappingJackson2HttpMessageConverter jacksonDateTimeConverter() {
		ObjectMapper objectMapper = new ObjectMapper();

		objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
		objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
		objectMapper.registerModule(new JSR310Module());

		MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
		converter.setObjectMapper(objectMapper);
		return converter;
	}
	
    private ExceptionHandlerExceptionResolver restErrorHandler(MessageSource messageSource) {
		ExceptionHandlerExceptionResolver exceptionResolver = new ExceptionHandlerExceptionResolver() {
			@Override
			protected ServletInvocableHandlerMethod getExceptionHandlerMethod(HandlerMethod handlerMethod,
																			Exception exception) {
				Method method = new ExceptionHandlerMethodResolver(RestErrorHandler.class).resolveMethod(exception);
				if (method != null) {
					return new ServletInvocableHandlerMethod(new RestErrorHandler(messageSource), method);
				}
				return super.getExceptionHandlerMethod(handlerMethod, exception);
			}
		};
		exceptionResolver.setMessageConverters(Arrays.asList(jacksonDateTimeConverter()));
		exceptionResolver.afterPropertiesSet();
		return exceptionResolver;
	}
	
	private LocalValidatorFactoryBean validator() {
		return new LocalValidatorFactoryBean();
	}

	private ViewResolver viewResolver() {
		InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();

		viewResolver.setViewClass(JstlView.class);
		viewResolver.setPrefix("/WEB-INF/jsp/");
		viewResolver.setSuffix(".jsp");

		return viewResolver;
	}
}

IMO sieht das ziemlich schrecklich aus. Wenn wir jedoch weiterhin dem DRY-Prinzip folgen wollen, können wir versuchen, dies zu bereinigen, indem wir unserer Testklassenhierarchie zwei neue abstrakte Klassen hinzufügen.

Auf DRY vertrauen wir

Wenn wir unser Chaos aufräumen wollen, müssen wir eine Klassenhierarchie erstellen, die aus den folgenden Klassen besteht:

  • Der AbstractControllerTest Klasse enthält die gemeinsamen Methoden, die von den anderen abstrakten Klassen und den eigentlichen Testklassen geteilt werden.
  • Der AbstractNormalControllerTest -Klasse erweitert den AbstractControllerTest -Klasse und bietet Unterstützung für das Schreiben von Unit-Tests für normale Spring MVC-Controller.
  • Der AbstractRESTControllerTest -Klasse erweitert den AbstractControllerTest -Klasse und bietet Unterstützung für das Schreiben von Komponententests für REST-Controller.

Die folgende Abbildung veranschaulicht die Struktur unserer Testklassenhierarchie:

Schauen wir uns jede abstrakte Klasse genauer an.

Der AbstractControllerTest Klasse enthält die folgenden Methoden:

  • Das setUp() -Methode wird aufgerufen, bevor unsere Testmethoden aufgerufen werden. Diese Methode ruft buildSystemUnderTest() auf -Methode und fügt den zurückgegebenen MockMvc ein Objekt in private mockMvc Feld.
  • Der getMockMvc() -Methode gibt den konfigurierten MockMvc zurück Objekt. Diese Methode wird von aktuellen Testklassen verwendet.
  • Der Validator() -Methode gibt eine neue LocalValidatorFactoryBean zurück Objekt. Diese Methode wird von anderen abstrakten Klassen aufgerufen, wenn sie das zu testende System konfigurieren.
  • Der abstrakte buildSystemTest() muss von anderen abstrakten Klassen implementiert werden. Die Implementierung dieser Methode muss einen konfigurierten MockMvc zurückgeben Objekt.
  • Der abstrakte getTestedController() -Methode gibt eine Instanz des getesteten Spring MVC-Controllers zurück. Diese Methode muss von tatsächlichen Testklassen implementiert werden. Es wird von unseren anderen abstrakten Klassen aufgerufen, wenn sie das zu testende System konfigurieren.

Der Quellcode des AbstractControllerTest Klasse sieht wie folgt aus:

import org.junit.Before;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
 
public abstract class AbstractControllerTest {
 
    private MockMvc mockMvc;
 
    @Before
    public void setUp() {
        mockMvc = buildSystemUnderTest();
    }
     
    protected MockMvc getMockMvc() {
        return mockMvc;
    }

	protected LocalValidatorFactoryBean validator() {
		return new LocalValidatorFactoryBean();
	}

	protected abstract MockMvc buildSystemUnderTest();
     
    protected abstract Object getTestedController();
}

Der AbstractNormalControllerTest Klasse enthält die folgenden Methoden:

  • Das buildSystemUnderTest() -Methode erstellt einen konfigurierten MockMvc Objekt und gibt das erstellte Objekt zurück.
  • Der ExceptionResolver() -Methode erstellt einen neuen SimpleMappingExceptionResolver Objekt, das Ausnahmen Ansichtsnamen zuordnet. Es gibt auch das erstellte Objekt zurück.
  • Der viewResolver() -Methode erstellt einen neuen InternalViewResolver Objekt, konfiguriert seine JSP-Unterstützung und gibt das erstellte Objekt zurück.

Der Quellcode des AbstractNormalControllerTest Klasse sieht wie folgt aus:

import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.handler.SimpleMappingExceptionResolver;
import org.springframework.web.servlet.view.InternalResourceViewResolver;
import org.springframework.web.servlet.view.JstlView;

public abstract class AbstractNormalControllerTest extends AbstractControllerTest {

    @Override
    protected MockMvc buildSystemUnderTest() {
        return MockMvcBuilders.standaloneSetup(getTestedController())
			.setHandlerExceptionResolvers(exceptionResolver())
            .setValidator(validator())
            .setViewResolvers(viewResolver())
			.build();
    }
	
	private HandlerExceptionResolver exceptionResolver() {
		SimpleMappingExceptionResolver exceptionResolver = new SimpleMappingExceptionResolver();

		Properties exceptionMappings = new Properties();	

		exceptionMappings.put(
			"net.petrikainulainen.spring.testmvc.todo.exception.TodoNotFoundException", 
			"error/404"
		);
		exceptionMappings.put("java.lang.Exception", "error/error");
		exceptionMappings.put("java.lang.RuntimeException", "error/error");

		exceptionResolver.setExceptionMappings(exceptionMappings);

		Properties statusCodes = new Properties();	

		statusCodes.put("error/404", "404");
		statusCodes.put("error/error", "500");

		exceptionResolver.setStatusCodes(statusCodes);

		return exceptionResolver;
	}

	private ViewResolver viewResolver() {
		InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();

		viewResolver.setViewClass(JstlView.class);
		viewResolver.setPrefix("/WEB-INF/jsp/");
		viewResolver.setSuffix(".jsp");

		return viewResolver;
	}
}

Der AbstractRESTControllerTest Klasse enthält die folgenden Methoden:

  • Das buildSystemUnderTest() -Methode erstellt einen konfigurierten MockMvc Objekt und gibt das erstellte Objekt zurück.
  • Der jacksonDateTimeConverter() -Methode erstellt einen neuen ObjectMapper , und konfiguriert es so, dass es Nullfelder ignoriert und Datums- und Uhrzeitobjekte von Java 8 unterstützt. Es verpackt das erstellte Objekt in einen neuen MappingJackson2HttpMessageConverter Objekt und gibt das Wrapper-Objekt zurück.
  • Der restErrorHandler() gibt einen neuen ExceptionHandlerExceptionResolver zurück Objekt, das die vom zu testenden System ausgelösten Ausnahmen verarbeitet.

Der Quellcode des AbstractRESTControllerTest Klasse sieht wie folgt aus:

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JSR310Module;
import org.springframework.context.support.StaticMessageSource;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver;
import org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod;

public abstract class AbstractRESTControllerTest extends AbstractControllerTest {

    @Override
    protected MockMvc buildSystemUnderTest() {
		StaticMessageSource messageSource = new StaticMessageSource();
		messageSource.setUseCodeAsDefaultMessage(true);	

        return MockMvcBuilders.standaloneSetup(getTestedController())
			.setHandlerExceptionResolvers(restErrorHandler(messageSource))
			.setMessageConverters(jacksonDateTimeConverter())
            .setValidator(validator())
			.build();
    }
	
	private MappingJackson2HttpMessageConverter jacksonDateTimeConverter() {
		ObjectMapper objectMapper = new ObjectMapper();

		objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
		objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
		objectMapper.registerModule(new JSR310Module());

		MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
		converter.setObjectMapper(objectMapper);
		return converter;
	}
	
    private ExceptionHandlerExceptionResolver restErrorHandler(MessageSource messageSource) {
		ExceptionHandlerExceptionResolver exceptionResolver = new ExceptionHandlerExceptionResolver() {
			@Override
			protected ServletInvocableHandlerMethod getExceptionHandlerMethod(HandlerMethod handlerMethod,
																			Exception exception) {
				Method method = new ExceptionHandlerMethodResolver(RestErrorHandler.class).resolveMethod(exception);
				if (method != null) {
					return new ServletInvocableHandlerMethod(new RestErrorHandler(messageSource), method);
				}
				return super.getExceptionHandlerMethod(handlerMethod, exception);
			}
		};
		exceptionResolver.setMessageConverters(Arrays.asList(jacksonDateTimeConverter()));
		exceptionResolver.afterPropertiesSet();
		return exceptionResolver;
	}
}

Wir haben jetzt die erforderlichen abstrakten Klassen erstellt. Unser nächster Schritt besteht darin, die eigentlichen Testklassen zu erstellen. Diese Klassen müssen den getTestedController() implementieren -Methode und erweitern Sie die richtige Basisklasse.

Der Quellcode des NormalTodoControllerTest Klasse sieht wie folgt aus:

import static org.mockito.Mockito.mock;
 
public NormalTodoControllerTest extends AbstractNormalControllerTest {
 
    private TodoService service;
     
	@Override
    protected Object getTestedController() {
        service = mock(TodoService.class);
        return new TodoController(service);
    }
}

Der Quellcode des RESTTodoControllerTest Klasse sieht wie folgt aus:

import static org.mockito.Mockito.mock;
 
public RESTTodoControllerTest extends AbstractRESTControllerTest {
 
    private TodoService service;
     
	@Override
    protected Object getTestedController() {
        service = mock(TodoService.class);
        return new TodoController(service);
    }
}

Nach viel harter Arbeit konnten wir eine Testklassenhierarchie erstellen, die (IMO) unser Problem nicht löst. Tatsächlich behaupte ich, dass diese Klassenhierarchie unsere Tests noch schwerer verständlich macht.

Auch wenn die einzelnen Klassen "ziemlich sauber" sind, besteht das Problem darin, dass wir, wenn wir wissen wollen, wie unsere Tests konfiguriert sind, den Quellcode der eigentlichen Testklasse lesen müssen, den Quellcode des AbstractNormalControllerTest Klasse oder den Quellcode des AbstractRESTControllerTest Klasse und den Quellcode des AbstractControllerTest Klasse. Mit anderen Worten, unser Code folgt dem DRY-Prinzip, aber der Preis für den erforderlichen mentalen Kontextwechsel ist viel höher.

Es ist klar, dass wir gegen das DRY-Prinzip verstoßen müssen.

Die Regeln brechen

Wenn wir dem DRY-Prinzip folgen und Vererbung zur Wiederverwendung von Code verwenden, erhalten wir am Ende eine beeindruckend aussehende Klassenhierarchie, die schwer zu verstehen ist.

Wir müssen einen anderen Weg finden, um den größten Teil des doppelten Codes zu eliminieren und das zu testende System so zu konfigurieren, dass es leicht verständlich ist und keinen mentalen Kontextwechsel erfordert. Ich denke, dass wir diese Ziele erreichen können, indem wir diese Regeln befolgen:

  • Wir müssen das zu testende System in unserer Testklasse konfigurieren. Mit anderen Worten, wir müssen @Before hinzufügen Methode in die eigentliche Testklasse.
  • Wir müssen die erforderlichen Mock-Objekte in der eigentlichen Testklasse erstellen.
  • Wenn das zu testende System andere Objekte (keine Mocks) benötigt, die von mehr als einer Testklasse verwendet werden, sollten wir diese Objekte mithilfe von Factory-Methoden oder Buildern erstellen.
  • Wenn das zu testende System andere Objekte (keine Mocks) benötigt, die nur von einer Testklasse verwendet werden, sollten wir diese Objekte in der Testklasse erstellen.

Lassen Sie uns unsere Tests neu schreiben, indem wir diese Regeln befolgen.

Zuerst , müssen wir die Factory-Methoden erstellen, die die Objekte erstellen, die zum Konfigurieren des zu testenden Systems erforderlich sind. Wir können dies tun, indem wir diesen Schritten folgen:

  1. Erstellen Sie eine WebTestConfig Klasse und stellen Sie sicher, dass sie nicht instanziiert werden kann.
  2. Fügen Sie die folgenden statischen Factory-Methoden zur WebTestConfig hinzu Klasse:
    1. Der ExceptionResolver() -Methode erstellt einen neuen SimpleMappingExceptionResolver Objekt, das Ausnahmen Ansichtsnamen zuordnet. Es gibt auch das erstellte Objekt zurück.
    2. Der jacksonDateTimeConverter() -Methode erstellt einen neuen ObjectMapper , und konfiguriert es so, dass es Nullfelder ignoriert und Datums- und Uhrzeitobjekte von Java 8 unterstützt. Es verpackt das erstellte Objekt in einen neuen MappingJackson2HttpMessageConverter Objekt und gibt das Wrapper-Objekt zurück.
    3. Die messageSource() -Methode erstellt eine neue StaticMessageSource -Objekt, konfiguriert es so, dass es den Nachrichtencode als Standardnachricht verwendet, und gibt das erstellte Objekt zurück.
    4. Der restErrorHandler() gibt einen neuen ExceptionHandlerExceptionResolver zurück Objekt, das die vom zu testenden System ausgelösten Ausnahmen verarbeitet.
    5. Der Validator() -Methode gibt eine neue LocalValidatorFactoryBean zurück Objekt.
    6. Der viewResolver() -Methode erstellt einen neuen InternalViewResolver Objekt, konfiguriert seine JSP-Unterstützung und gibt das erstellte Objekt zurück.

Der Quellcode der WebTestConfig Klasse sieht wie folgt aus:

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JSR310Module;
import org.springframework.context.MessageSource;
import org.springframework.context.support.StaticMessageSource;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver;
import org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.handler.SimpleMappingExceptionResolver;
import org.springframework.web.servlet.view.InternalResourceViewResolver;
import org.springframework.web.servlet.view.JstlView;

public final class WebTestConfig {

	private WebTestConfig() {}

	public static HandlerExceptionResolver exceptionResolver() {
        SimpleMappingExceptionResolver exceptionResolver = new SimpleMappingExceptionResolver();
 
        Properties exceptionMappings = new Properties();    
 
        exceptionMappings.put(
            "net.petrikainulainen.spring.testmvc.todo.exception.TodoNotFoundException", 
            "error/404"
        );
        exceptionMappings.put("java.lang.Exception", "error/error");
        exceptionMappings.put("java.lang.RuntimeException", "error/error");
 
        exceptionResolver.setExceptionMappings(exceptionMappings);
 
        Properties statusCodes = new Properties();  
 
        statusCodes.put("error/404", "404");
        statusCodes.put("error/error", "500");
 
        exceptionResolver.setStatusCodes(statusCodes);
 
        return exceptionResolver;
    }
	
	public static MappingJackson2HttpMessageConverter jacksonDateTimeConverter() {
		ObjectMapper objectMapper = new ObjectMapper();
 
		objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
		objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
		objectMapper.registerModule(new JSR310Module());
 
		MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();	
		converter.setObjectMapper(objectMapper);
		return converter;
	}
	
	public static MessageSource messageSource() {
		StaticMessageSource messageSource = new StaticMessageSource();
		messageSource.setUseCodeAsDefaultMessage(true);
		return messageSource;
	}
	
	public static ExceptionHandlerExceptionResolver restErrorHandler(MessageSource messageSource) {
		ExceptionHandlerExceptionResolver exceptionResolver = new ExceptionHandlerExceptionResolver() {
			@Override
			protected ServletInvocableHandlerMethod getExceptionHandlerMethod(HandlerMethod handlerMethod,
																			  Exception exception) {
				Method method = new ExceptionHandlerMethodResolver(RestErrorHandler.class).resolveMethod(exception);
				if (method != null) {
					return new ServletInvocableHandlerMethod(new RestErrorHandler(messageSource), method);
				}
				return super.getExceptionHandlerMethod(handlerMethod, exception);
			}
		};
		exceptionResolver.setMessageConverters(Arrays.asList(jacksonDateTimeConverter()));
		exceptionResolver.afterPropertiesSet();
		return exceptionResolver;
	}
	
	public static LocalValidatorFactoryBean validator() {
		return new LocalValidatorFactoryBean();
	}
	
    public static ViewResolver viewResolver() {
        InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
 
        viewResolver.setViewClass(JstlView.class);
        viewResolver.setPrefix("/WEB-INF/jsp/");
        viewResolver.setSuffix(".jsp");
 
        return viewResolver;
    }
}

Nachdem wir diese Factory-Methoden erstellt haben, müssen wir unsere Testklassen umschreiben. Jede Testklasse hat zwei Verantwortlichkeiten:

  • Es erstellt das erforderliche Scheinobjekt.
  • Es konfiguriert das zu testende System und erstellt einen neuen MockMvc Objekt, das zum Schreiben von Unit-Tests für Controller-Methoden verwendet werden kann.

Nachdem wir diese Änderungen am NormalTodoControllerTest vorgenommen haben Klasse sieht der Quellcode wie folgt aus:

import org.junit.Before;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.mockito.Mockito.mock;
 
public class NormalTodoControllerTest {

	private MockMvc mockMvc;
	private TodoService service;

	@Before
	public void configureSystemUnderTest()
		service = mock(TodoService.class);

		mockMvc = MockMvcBuilders.standaloneSetup(new TodoController(service))
			.setHandlerExceptionResolvers(WebTestConfig.exceptionResolver())
			.setValidator(WebTestConfig.validator())
			.setViewResolvers(WebTestConfig.viewResolver())
			.build();
	}
}

Nachdem wir den RESTTodoControllerTest umgeschrieben haben Klasse sieht der Quellcode wie folgt aus:

import org.junit.Before;
import org.springframework.context.MessageSource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.mockito.Mockito.mock;
 
public class RESTTodoControllerTest {

	private MockMvc mockMvc;
	private TodoService service;

	@Before
	public void configureSystemUnderTest()
		MessageSource messageSource = WebTestConfig.messageSource();
		service = mock(TodoService.class);
 
		mockMvc = MockMvcBuilders.standaloneSetup(new TodoController(service))
			.setHandlerExceptionResolvers(WebTestConfig.restErrorHandler(messageSource))
			.setMessageConverters(WebTestConfig.jacksonDateTimeConverter())
			.setValidator(WebTestConfig.validator())
			.build();
	}
}

Lassen Sie uns die Vor- und Nachteile dieser Lösung bewerten.

Dies ist ein Kompromiss

Jede Softwaredesign-Entscheidung ist ein Kompromiss, der sowohl Vor- als auch Nachteile hat. Dies ist keine Ausnahme von dieser Regel .

Wenn wir unsere Tests konfigurieren, indem wir die im vorherigen Abschnitt beschriebenen Regeln befolgen, können wir von diesen Vorteilen profitieren:

  • Wir können uns einen allgemeinen Überblick über unsere Konfiguration verschaffen, indem wir die Methode lesen, die das zu testende System konfiguriert. Wenn wir mehr Informationen über die Konfiguration einer bestimmten Komponente erhalten möchten, können wir einfach die Factory-Methode lesen, die sie erstellt und konfiguriert. Mit anderen Worten, unser Ansatz minimiert die Kosten der Kontextverschiebung.
  • Wir können unsere Testklassen konfigurieren, indem wir nur die Komponenten verwenden, die für jede Testmethode relevant sind. Dies macht die Konfiguration verständlicher und hilft uns, Zeit zu sparen, wenn ein Testfall fehlschlägt.

Auf der anderen Seite sind die Nachteile dieses Ansatzes:

  • Wir müssen doppelten Code schreiben. Dies dauert etwas länger, als die erforderliche Konfiguration in die Basisklasse (oder -klassen) zu übertragen.
  • Wenn wir Änderungen an unserer Konfiguration vornehmen müssen, müssen wir diese Änderungen möglicherweise an jeder Testklasse vornehmen.

Wenn unsere nur Ziel ist es, unsere Tests so schnell wie möglich zu schreiben, ist es klar, dass wir doppelten Code und Konfigurationen eliminieren sollten.

Das ist jedoch nicht mein einziges Ziel.

Es gibt drei Gründe, warum ich denke, dass die Vorteile dieses Ansatzes die Nachteile überwiegen:

  • Vererbung ist nicht das richtige Werkzeug zur Wiederverwendung von Code oder Konfiguration.
  • Wenn ein Testfall fehlschlägt, müssen wir das Problem so schnell wie möglich finden und lösen, und eine saubere Konfiguration wird uns helfen, dieses Ziel zu erreichen.
  • Wenn wir diesen Ansatz verwenden, schreiben wir (meiner Meinung nach) sauberen Testcode. Dies ermutigt andere Entwickler, dasselbe zu tun.

Mein Standpunkt in dieser Angelegenheit ist glasklar. Es bleibt jedoch noch eine sehr wichtige Frage offen:

Werden Sie einen anderen Kompromiss eingehen?


Java-Tag