Java >> Java Tutorial >  >> Tag >> assert

Saubere Tests schreiben – Behauptungen durch eine domänenspezifische Sprache ersetzen

Automatisierte Tests sind wertlos, wenn sie nichts behaupten, aber das Problem der regulären JUnit-Assertionen ist, dass sie die falsche Sprache sprechen und unordentlich werden, wenn wir viele davon schreiben müssen.

Wenn wir Tests schreiben wollen, die leicht zu verstehen und zu warten sind, müssen wir einen besseren Weg finden, Behauptungen zu schreiben .

Dieser Blogbeitrag identifiziert die Probleme der „Standard“-JUnit-Assertionen und beschreibt, wie wir diese Probleme lösen, indem wir diese Assertionen durch eine domänenspezifische Sprache ersetzen.

Daten sind nicht so wichtig

In meinem vorherigen Blogbeitrag habe ich zwei Probleme identifiziert, die durch datenzentrische Tests verursacht werden. Obwohl dieser Blogbeitrag über die Erstellung neuer Objekte sprach, gelten diese Probleme auch für Behauptungen.

Frischen wir unser Gedächtnis auf und werfen wir einen Blick auf den Quellcode unseres Komponententests, der sicherstellt, dass das registerNewUserAccount(RegistrationForm userAccountData) Methode des RepositoryUserService Die Klasse funktioniert wie erwartet, wenn ein neues Benutzerkonto mit einer eindeutigen E-Mail-Adresse und einem Social-Sign-In-Anbieter erstellt wird.

Unser Unit-Test sieht wie folgt aus (der relevante Code ist hervorgehoben):

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

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

Wie wir sehen können, stellen die in unserem Komponententest gefundenen Zusicherungen sicher, dass die Eigenschaftswerte des zurückgegebenen User Objekt sind richtig. Unsere Zusicherungen stellen Folgendes sicher:

  • Der Wert der E-Mail Eigenschaft ist korrekt.
  • Der Wert von firstName Eigenschaft ist korrekt.
  • Der Wert von lastName Eigenschaft ist korrekt.
  • Der Wert des signInProvider ist richtig.
  • Der Wert der Rolle Eigenschaft ist korrekt.
  • Das Passwort ist null.

Das ist natürlich ziemlich offensichtlich, aber es ist wichtig, diese Behauptungen auf diese Weise zu wiederholen, weil es uns hilft, das Problem unserer Behauptungen zu identifizieren. Unsere Behauptungen sind datenzentriert und das bedeutet:

  • Der Leser muss die verschiedenen Zustände des zurückgegebenen Objekts kennen . Wenn wir zum Beispiel an unser Beispiel denken, muss der Leser wissen, dass die E-Mail , Vorname , Nachname und signInProvider Eigenschaften des zurückgegebenen RegistrationForm -Objekt haben Nicht-Null-Werte und den Wert des Passworts -Eigenschaft null ist, bedeutet dies, dass das Objekt eine Registrierung ist, die mithilfe eines Social Sign-In-Anbieters vorgenommen wird.
  • Wenn das erstellte Objekt viele Eigenschaften hat, verunreinigen unsere Behauptungen den Quellcode unserer Tests. Wir sollten daran denken, dass wir, obwohl wir sicherstellen wollen, dass die Daten des zurückgegebenen Objekts korrekt sind, viel wichtiger ist, dass wir den Zustand des zurückgegebenen Objekts beschreiben .

Mal sehen, wie wir unsere Behauptungen verbessern können.

Aussagen in eine domänenspezifische Sprache umwandeln

Sie haben vielleicht bemerkt, dass die Entwickler und die Domänenexperten oft unterschiedliche Begriffe für die gleichen Dinge verwenden. Mit anderen Worten, Entwickler sprechen nicht die gleiche Sprache wie die Domänenexperten. Dies führt zu unnötiger Verwirrung und Spannungen zwischen den Entwicklern und den Domänenexperten .

Domänengesteuertes Design (DDD) bietet eine Lösung für dieses Problem. Eric Evans führte den Begriff ubiquitäre Sprache in seinem Buch mit dem Titel Domain-Driven Design ein.

Wikipedia spezifiziert allgegenwärtige Sprache wie folgt:

Allgegenwärtige Sprache ist eine Sprache, die um das Domänenmodell herum strukturiert ist und von allen Teammitgliedern verwendet wird, um alle Aktivitäten des Teams mit der Software zu verbinden.

Wenn wir Behauptungen schreiben wollen, die die „richtige“ Sprache sprechen, müssen wir die Kluft zwischen den Entwicklern und den Domänenexperten überbrücken. Mit anderen Worten, wir müssen eine domänenspezifische Sprache zum Schreiben von Assertions erstellen.

Implementieren unserer domänenspezifischen Sprache

Bevor wir unsere domänenspezifische Sprache implementieren können, müssen wir sie entwerfen. Wenn wir eine domänenspezifische Sprache für unsere Behauptungen entwerfen, müssen wir diese Regeln befolgen:

  1. Wir müssen den datenzentrierten Ansatz aufgeben und mehr an den echten Benutzer denken, dessen Informationen von einem Benutzer gefunden werden Objekt.
  2. Wir müssen die Sprache verwenden, die von den Domänenexperten gesprochen wird.

Wenn wir diese beiden Regeln befolgen, können wir die folgenden Regeln für unsere domänenspezifische Sprache erstellen:

  • Ein Benutzer hat einen Vornamen, einen Nachnamen und eine E-Mail-Adresse.
  • Ein Benutzer ist ein registrierter Benutzer.
  • Ein Benutzer wird über einen Social-Sign-Anbieter registriert, was bedeutet, dass dieser Benutzer kein Passwort hat.

Nachdem wir die Regeln unserer domänenspezifischen Sprache festgelegt haben, sind wir bereit, sie zu implementieren. Dazu erstellen wir eine benutzerdefinierte AssertJ-Assertion, die die Regeln unserer domänenspezifischen Sprache implementiert.

Der Quellcode unserer benutzerdefinierten Assertion-Klasse sieht wie folgt aus:

import org.assertj.core.api.AbstractAssert;
import org.assertj.core.api.Assertions;

public class UserAssert extends AbstractAssert<UserAssert, User> {

    private UserAssert(User actual) {
        super(actual, UserAssert.class);
    }

    public static UserAssert assertThatUser(User actual) {
        return new UserAssert(actual);
    }

    public UserAssert hasEmail(String email) {
        isNotNull();

        Assertions.assertThat(actual.getEmail())
                .overridingErrorMessage( "Expected email to be <%s> but was <%s>",
                        email,
                        actual.getEmail()
                )
                .isEqualTo(email);

        return this;
    }

    public UserAssert hasFirstName(String firstName) {
        isNotNull();

        Assertions.assertThat(actual.getFirstName())
                .overridingErrorMessage("Expected first name to be <%s> but was <%s>",
                        firstName,
                        actual.getFirstName()
                )
                .isEqualTo(firstName);

        return this;
    }

    public UserAssert hasLastName(String lastName) {
        isNotNull();

        Assertions.assertThat(actual.getLastName())
                .overridingErrorMessage( "Expected last name to be <%s> but was <%s>",
                        lastName,
                        actual.getLastName()
                )
                .isEqualTo(lastName);

        return this;
    }

    public UserAssert isRegisteredByUsingSignInProvider(SocialMediaService signInProvider) {
        isNotNull();

        Assertions.assertThat(actual.getSignInProvider())
                .overridingErrorMessage( "Expected signInProvider to be <%s> but was <%s>",
                        signInProvider,
                        actual.getSignInProvider()
                )
                .isEqualTo(signInProvider);

        hasNoPassword();

        return this;
    }

    private void hasNoPassword() {
        isNotNull();

        Assertions.assertThat(actual.getPassword())
                .overridingErrorMessage("Expected password to be <null> but was <%s>",
                        actual.getPassword()
                )
                .isNull();
    }

    public UserAssert isRegisteredUser() {
        isNotNull();

        Assertions.assertThat(actual.getRole())
                .overridingErrorMessage( "Expected role to be <ROLE_USER> but was <%s>",
                        actual.getRole()
                )
                .isEqualTo(Role.ROLE_USER);

        return this;
    }
}

Wir haben jetzt eine domänenspezifische Sprache zum Schreiben von Zusicherungen an Benutzer erstellt Objekte. Unser nächster Schritt besteht darin, unseren Einheitentest so zu ändern, dass er unsere neue domänenspezifische Sprache verwendet.

Ersetzen von JUnit-Zusicherungen durch eine domänenspezifische Sprache

Nachdem wir unsere Zusicherungen umgeschrieben haben, um unsere domänenspezifische Sprache zu verwenden, sieht der Quellcode unseres Einheitentests wie folgt aus (der relevante Teil ist hervorgehoben):

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

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

Unsere Lösung hat folgende Vorteile:

  • Unsere Behauptungen verwenden die Sprache, die von den Domänenexperten verstanden wird. Damit ist unser Test eine ausführbare Spezifikation, die leicht verständlich und immer aktuell ist.
  • Wir müssen keine Zeit damit verschwenden herauszufinden, warum ein Test fehlgeschlagen ist. Unsere benutzerdefinierten Fehlermeldungen stellen sicher, dass wir wissen, warum es fehlgeschlagen ist.
  • Wenn die API des Benutzers Klassenänderungen müssen wir nicht jede Testmethode reparieren, die Zusicherungen an Benutzer schreibt Objekte. Die einzige Klasse, die wir ändern müssen, ist UserAssert Klasse. Mit anderen Worten, durch das Verschieben der eigentlichen Behauptungslogik weg von unserer Testmethode wurde unser Test weniger spröde und einfacher zu warten.

Nehmen wir uns einen Moment Zeit, um zusammenzufassen, was wir aus diesem Blogbeitrag gelernt haben.

Zusammenfassung

Wir haben unsere Behauptungen nun in eine domänenspezifische Sprache transformiert. Dieser Blogpost hat uns drei Dinge gelehrt:

  • Das Befolgen des datenzentrierten Ansatzes führt zu unnötiger Verwirrung und Spannungen zwischen den Entwicklern und den Domänenexperten.
  • Das Erstellen einer domänenspezifischen Sprache für unsere Zusicherungen macht unsere Tests weniger spröde, da die eigentliche Zusicherungslogik in benutzerdefinierte Zusicherungsklassen verschoben wird.
  • Wenn wir Behauptungen in einer domänenspezifischen Sprache schreiben, verwandeln wir unsere Tests in ausführbare Spezifikationen, die leicht verständlich sind und die Sprache der Domänenexperten sprechen.

Java-Tag