Was ist Spott
"Mocking" ist ein Wort, das für verschiedene Menschen unterschiedliche Bedeutungen haben kann. Andere Begriffe, die Sie vielleicht kennen, sind "stubs" oder "test doubles".
Hier verwende ich den Begriff "Mocking" speziell für "magisches", Makro-, Reflection- oder Monkey-Patch-basiertes Mocking.
Beispiele für Verspottung:
- Scalamock
- Mockito
- python
mock.patch
Wenn wir eine abgespeckte Version einer Schnittstelle implementieren, um sie in Tests zu verwenden, z.B. um Daten aus der Konserve zurückzugeben oder einfach mit einem Fehler zu scheitern, nenne ich das einen Stub.
Beispiele für Stubs:
new MyInterface { def method() = throw new Exception("fail") }new UserRepository { def getUser(id: Int) = User(id, "fred", "flintstone") }
Wenn ich von einer zusätzlichen Implementierung einer Schnittstelle spreche, die die Semantik der Schnittstelle beachtet, aber in Bezug auf Komplexität oder Abhängigkeiten reduziert ist, nenne ich das ein Testdouble oder einen Simulator.
Beispiele für Test-Doubles und Simulatoren:
- Verwendung einer
Mapals Ersatz für eine Datenbank oder ein Dateisystem. - Verwendung einer prozessinternen Version eines externen Dienstes, wie embedded mongo.
- Verwendung einer lokalen Version eines externen Dienstes, wie dynalite oder minio s3.
Warum Mocks nicht die richtige Wahl sind
- Es ist leicht, Mock-Instanzen falsch zu machen - keine Garantie, dass sie mit einer korrekten Implementierung übereinstimmen, was zu spröden Tests führen kann.
- Mocks entmutigen ein gutes Schnittstellendesign, indem sie das Testen von Implementierungsdetails anstelle von Verträgen fördern.
- Tests sollten eine "ausführbare Dokumentation" sein.
- Da sie magischere Funktionen wie Makros und Reflexion verwenden können, sind sie weniger portabel - z.B.unterstützt scalamock nicht scala 3 und hat keine aktuelle Roadmap dafür.
Sprödigkeitstests
Mock-Instanzen sind einfacher zu konstruieren, wenn sie keine Logik implementieren, sondern nur fertige Ergebnisse zurückgeben. Dies kann jedoch ein Hindernis sein, wenn sich die Art und Weise, wie die Aufrufer ihre Abhängigkeiten aufrufen, ändert.
Nehmen wir zum Beispiel einen Fall, in dem wir ein System haben, das Schnappschüsse aus einem ereignisgesteuerten Stream speichert.
trait SnapshotStorage {
def find(id: EntityId): IO[Option[Entity]]
def update(id: EntityId, entity: Entity): IO[Unit]
}
class EventProcessor(store: SnapshotStorage) {
def handle(event: Event): IO[Unit] =
store.update(event.entityId, event.entity)
}
Und dafür hätten wir natürlich einen Test:
test("should store new entities") {
val storage = mock[SnapshotStorage]
(storage.update _).expects(*, *).returning(())
val ep = new EventProcessor(storage)
ep.handle(testEvent()).attempt.map(result => assert(result.isRight))
}
Jetzt möchten wir unseren Prozessor so aktualisieren, dass er nur dann eine Aktualisierung vornimmt, wenn das empfangene Ereignis neuer ist als das gespeicherte:
class EventProcessor(store: SnapshotStorage) {
def handle(event: Event): IO[Unit] = {
def update = store.update(event.entityId, event.entity)
store.find(event.entityId).flatMap {
case Some(existing) =>
if (existing.version <= event.entity.version) update else IO.unit case None => update
}
}
}
Angesichts dieser Code-Aktualisierung, unser bestehender Test schlägt fehl, obwohl das darin beschriebene Verhalten noch funktioniert. Wir haben den Code in EventProcessor aber unser Testcode musste die Art der Interaktion mit SnapshotStorage. Das ist genau die Art von enger Kopplung, die eine Codebasis im Laufe der Zeit verkrüppeln und die Kosten für das Unternehmen erhöhen kann, da sie neue Änderungen erschwert und die Einführung von Rückschritten erleichtert. In einem lose gekoppelten System sollte das Hinzufügen neuer Funktionen nicht dazu führen, dass wir die Tests für alte Funktionen neu schreiben müssen.
Wenn wir anstelle von Mocks ein Testdouble verwenden würden, würde der alte Test ohne Änderung weiter funktionieren.
class TestStorage(data: Ref[Map[EntityId, Entity]]) extends SnapshotStorage {
def find(id: EntityId): IO[Option[Entity]] =
data.get.map { entityMap =>
entityMap.get(id)
}
def update(id: EntityId, entity: Entity): IO[Unit]
data.update { entityMap =>
entityMap.updated(id, entity)
}
}
Entmutigendes Interface-Design
Eine der Möglichkeiten, wie magisches Mocking das Oberflächendesign entmutigen kann, besteht darin, dass es zu einfach ist, Tests zu schreiben, die fest mit einer schlecht entworfenen Klasse kodiert sind. Anstatt dass die Reibung durch die Klasse zu einer Verbesserung des Codes führt, kann man sie einfach übergehen.
Dies ist zum Beispiel ein Code, den ich schon einmal gesehen habe:
class ElasticsearchRepository(client: ElasticClient) {
def find(documentId: String)(implicit ec: ExecutionContext, log: Logger): Future[Json] = /* impl */
def upload(document: Json)(implicit ec: ExecutionContext, log: Logger): Future[String] = /* impl */
}
Dieser Code hat vor allem zwei Probleme:
- Da es keine Schnittstelle hat, wird es schwieriger, ein Testdouble zu erstellen. Sie müssen ein
ElasticClientselbst wenn Sie nicht beabsichtigen, sie zu verwenden. Dies führt zu sinnlosem Boilerplate-Code in Tests und erhöht das Potenzial für Programmierfehler. - Sie ergänzt die Implementierungsdetails (Protokollierung und Thread-Pools) mit der Geschäftsdomäne (Suche nach Daten in Elasticsearch). Eine gute Schnittstelle sollte nur Informationen über ihre Domäne enthalten, und Threadpools sind nicht Teil der semantischen Domäne eines Elasticsearch-Repositorys.
Das Refactoring zu einer Schnittstelle löst diese Probleme:
trait ElasticsearchRepository {
def find(documentId: String): Future[Json]
def upload(document: Json): Future[String]
}
So erhalten wir einen einfacheren Code und haben weniger Probleme beim Testen.
Tests als ausführbare Dokumentation
Wir schreiben Tests, weil wir Vertrauen in die Korrektheit der Systeme gewinnen wollen, die wir schreiben, und weil wir das Risiko einer Regression verringern wollen. Die meisten Tests sind zumindest teilweise unrealistisch, aber Sie erhalten das meiste Vertrauen und die größte Risikominderung, wenn der Code in den Tests genau derselbe ist wie der Code, den die Leute in Wirklichkeit schreiben.
In unserem Produktionscode verwenden wir keine Mocks, sondern einfache Schnittstellen, Methoden und Werte. Ein Test, der dieselben Schnittstellen, Methoden und Werte verwendet, ist also näher an dem, was in der Produktion tatsächlich läuft.
Nehmen wir an, wir haben ein UserService , mit dem wir unsere Benutzerkontoinformationen aus einer Datenbank abfragen können, und wir möchten Tests für unser NotificationService schreiben, das diese Informationen verwendet, um eine Vorlage mit Informationen über den Benutzer zu formatieren.
trait UserService {
def find(id: UserId): IO[User] // fails if not found
def register(email: EmailAddress, name: String): IO[UserId]
}
trait NotificationService {
def formatNotification(userId: UserId, template: NotificationTemplate): IO[String]
}
Und hier ist unsere datenbankgestützte Implementierung davon:
class DbUserService(xa: doobie.Transactor[IO]) extends UserService {
def find(id: UserId): IO[User] =
sql"SELECT email, name FROM users WHERE id = ${id}"
.query[User]
.unique
.transact(xa)
def register(email: EmailAddress, name: String): IO[UserId] =
sql"INSERT INTO users (email, name) VALUES ($email, $name)"
.update
.transact(xa)
}
class NotificationServiceImpl(users: UserService) extends NotificationService {
def formatNotification(userId: UserId, template: NotificationTemplate): IO[String] =
users.find(userId)
.map { user =>
formatTemplate(user, template)
}
private def formatTemplate(user: User, template: NotificationTemplate): String = ???
}
Hier sehen Sie, wie wir diese mit und ohne Mocks testen können:
val fred = User(EmailAddress("fred@flintstones.com"), "Fred Flintstone")
test("with scalamock") {
val users = mock[DbUserService] // 1
(users.find _).expects(*).returning(fred) // 2
val ns = new NotificationServiceImpl(users)
ns.formatNotification(fredId, NotificationTemplate.bowlingNight)
.attempt
.map { result => assert(result.isRight) }
}
test("using a test double") {
// 3: a UserServiceTestDouble built on top of Ref+Map can be defined elsewhere
for {
users <- UserServiceTestDouble.create
fredId <- users.register(fred) // 4
ns = new NotificationServiceImpl(users)
result <- ns.formatNotification(fredId, NotificationTemplate.bowlingNight).attempt
} yield { assert(result.isRight) }
}
Aus dem obigen Codebeispiel:
- Hier dürfen wir datenbankbasierte Mock-Instanzen konstruieren, obwohl wir nur die Schnittstelle verwenden wollen - das erhöht die Wahrscheinlichkeit, dass wir falschen oder eng gekoppelten Code schreiben.
- Wir setzen einen expliziten Rückgabewert für die Methode "find". Aber woher wissen wir, dass
findist, was wir brauchen? Es ist ein Detail der Implementierung vonNotificationService. - Wenn wir ein Testdouble verwenden, isolieren und abstrahieren wir wiederholten Einrichtungscode - eine übliche Praxis, um gesunden Code zu schreiben.
- Wir schreiben unseren Test direkt in der Domänensprache: Zuerst wird ein Benutzer registriert. Dann benachrichtigen wir diesen Benutzer. Die Benachrichtigung sollte nicht fehlschlagen. Im Gegensatz zu Punkt 2, bei dem der Test hart kodiertes Wissen über
NotificationServicebestimmte Methoden seiner Abhängigkeiten aufruft.
Wie Sie Mocks vermeiden
Da wir nun wissen, was wir vermeiden wollen, was sollten wir stattdessen tun?
Es gibt ein paar wichtige Techniken, die wir anwenden können, um eine gesunde Codebasis zu erhalten:
- Schnittstellen verwenden
- Verwenden Sie Test-Doubles und/oder Simulatoren
- Schreiben Sie Ihren Testcode nach demselben Standard wie den Produktionscode
Schnittstellen verwenden
Das stärkste Werkzeug, das wir zum Schreiben von sauberem Code haben, ist die Verwendung von Schnittstellen.
Halten Sie Ihre Schnittstellen einfach und überschaubar und versuchen Sie, sie zu isolieren. Stellen Sie sich vor, Sie schreiben die betreffende Teilkomponente als Bibliothek. Was würden Sie erwarten, wenn Sie die Bibliothek "von der Stange" aus einer Open-Source-Abhängigkeit nehmen würden? Schreiben Sie das. Vermeiden Sie die Einmischung zusätzlicher Details.
Eine gute Faustregel für Ihre Geschäftsschnittstellen ist, dass sie nur Begriffe erwähnen sollten, die in Ihrer Geschäftsdomäne relevant sind. Vermeiden Sie die Vermischung von Konzepten aus mehreren Ebenen.
Abhängigkeiten von Klassen sind in der Regel Implementierungsdetails und sollten in Ihrem Klassenkonstruktor erscheinen, nicht in Ihren Methoden. Beispiele hierfür wären: Ausführungskontext, Logger, Instanzen von Typklassen, Verbindungspools, Bibliotheksobjekte von Drittanbietern.
Verwenden Sie Test-Doubles und Simulatoren
Schreiben Sie Ihre Tests mit einfachen Testdoubles und Simulatoren. Es ist gar nicht so schlecht, wenn Sie Tests haben, die einen externen Prozess aufrufen! Mit Tools wie Docker können Sie ganz einfach externe Ressourcen wie S3 oder Postgres für Ihre Tests zur Verfügung stellen. Sie müssen das Rad nicht neu erfinden.
Behalten Sie die gleichen Kodierungsstandards bei
Sie möchten, dass für Ihre Tests dieselben Codierungsstandards gelten wie für Ihren Produktionscode. Die gleichen Dinge, die den Produktionscode lesbar machen, machen auch die Tests lesbar. Refaktorieren Sie wiederholte Boilerplate, verwenden Sie Methoden, halten Sie sich an Schnittstellen und nicht an Implementierungsdetails.
Wann sind magische Spötteleien gut?
Alles in der Software ist ein Kompromiss - es ist sehr selten, dass es eine Sache gibt, die in jedem einzelnen Fall besser ist als eine andere. Wenn Sie das bedenken, wann sollten Sie dann überhaupt noch magische Mocks verwenden? Was bieten sie, was wir nicht einfach durch das Schreiben von Code mit Schnittstellen und Verträgen erreichen können?
Lassen Sie uns noch einmal auf einige der Punkte eingehen, die Mocks unerwünscht machen:
- Sie verbergen Design Smells, indem sie es einfach machen, unrealistische Setups zu konstruieren und Klassenabhängigkeiten zu umgehen.
- Sie erleichtern das Schreiben einfacher Daten-Stubs für komplexe und schlecht gestaltete Schnittstellen.
- Sie regen dazu an, dass Tests Implementierungsdetails enthalten, anstatt sich auf Schnittstellenverträge zu stützen.
Wann also sind diese Aspekte Stärken? Meiner Meinung nach ist das beste Argument für den Einsatz von Mocks, eine ungesunde Codebasis zu rehabilitieren. Wenn eine Codebasis schlecht gestaltete Schnittstellen, fehlende Tests und undokumentierte Verträge aufweist, dann können Mocks Ihnen helfen, die Lücke zu schließen. Eine gängige Technik zur Sanierung ungesunder Codebasen ist die Verwendung von "Charakterisierungstests". Im Gegensatz zu den meisten anderen Tests geht es bei Charakterisierungstests nicht darum, Vertrauen in Ihr Design und die allgemeine Korrektheit des Codes zu schaffen. Stattdessen dienen sie dazu, das Verhalten eines unbekannten Systems zu beschreiben. "Ich weiß nicht, wie diese Blackbox im Allgemeinen funktioniert, aber wenn ich diesen Knopf drücke, leuchtet sie auf." Das ist die Art von Charakterisierungstests, die Sie schreiben können. Wenn Sie solche Tests schreiben, gewinnen Sie an Dokumentation und Regressionssicherheit.
Mocks können helfen, den Knoten der verworrenen Abhängigkeiten zu durchtrennen und Tests zu schreiben, die beschreiben, wie das System heute funktioniert. Diese Tests füllen die Lücke der fehlenden Dokumentation und bieten eine Versicherung gegen versehentliches Brechen dieser Verhaltensweisen.
Allerdings ist dies kein idealer Zustand, in dem sich eine Codebasis langfristig befinden sollte. Sie sollten den Weg zu einer gesunden Codebasis festlegen und Mocks als einen Schritt auf diesem Weg verwenden. Sobald sie ihren Zweck erfüllt haben und Ihre Codebasis wieder in einem guten Zustand ist, mit vernünftigen Schnittstellen, Verträgen und wiederverwendbaren Codestrukturen, sollten Sie für Ihre neuen Tests einen sauberen Stil einplanen, anstatt die Magie fortzusetzen.
Zusammenfassung
Wir haben gesehen, wie magische Mocks in Tests zu sprödem Testcode führen können, der das Risiko erhöht, dass Ihre Funktionen rechtzeitig und korrekt funktionieren. Um sie zu vermeiden, können wir Schnittstellen verwenden und intelligentere Testhelfer schreiben, wie Testdoubles oder externe Abhängigkeitssimulatoren. Mocks können immer noch das richtige Werkzeug sein, um eine ungesunde Codebasis zu sanieren. Wenn Sie sie mit Bedacht einsetzen, können Sie einer undokumentierten Codebasis schnell Tests zur Charakterisierung hinzufügen, aber Sie sollten einen Plan haben, wie Sie sie später nicht mehr verwenden.
Viel Spaß beim Testen!
Verfasst von
Gavin Bisesi
Unsere Ideen
Weitere Blogs

Testgetriebene Entwicklung (TDD) mit dbt: Erst testen, dann SQL
Testgetriebene Entwicklung mit dbt: Erst testen, dann SQL Wenn Sie mehr als drei Tage als Analytik-Ingenieur verbracht haben, hatten Sie...
Dumky de Wilde

Python Mocking, die heimtückischen Bits
Bei dem Versuch, eine Funktion in meinem Python-Code zu spiegeln, bin ich auf diesen hervorragenden Blog von Durga Swaroop Perla gestoßen. Der Blog...
Jan Vermeir
Contact

