Typisierte Mocks, satisfies und unknown statt fauler Testdaten
Testcode bekommt oft einen Freifahrtschein bei der Typsicherheit, weil Deadlines drücken, ein Mock schnell kopiert wird oder es ja angeblich nur ein Test ist. Dabei sollen gerade Tests Typregressionen im getesteten Code zuverlässig aufdecken. Dieser Artikel zeigt praxistaugliche typisierte Mocking-Patterns mit vi.mocked, satisfies und Mock-Factories sowie unknown und zod als Ersatz für any bei losen Testdaten.
Inhaltsverzeichnis
- 1. Warum Testcode einen Freifahrtschein bei der Typsicherheit bekommt
- 2. Warum any in Tests genauso riskant ist wie in Produktivcode
- 3. Typisierte Mocks mit vi.mocked() und Mocked<T>
- 4. satisfies: Tippfehler abfangen, ohne den Typ zu verbreitern
- 5. Typisierte Mock-Factories statt wiederholtem Boilerplate
- 6. unknown und Type Guards statt any für lose Testdaten
- 7. Laufzeitvalidierung mit zod für API-Response-Fixtures
- 8. Wann ein eng begrenztes any im Test noch pragmatisch ist
- 9. any vs. typisierte Patterns im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Testcode einen Freifahrtschein bei der Typsicherheit bekommt
Kaum ein Bereich einer Codebasis bekommt so oft einen Freifahrtschein bei der Typsicherheit wie Testcode. Unter Deadline-Druck wird der schnellste Weg zu einem grünen Test gewählt: ein Mock-Objekt als any deklarieren, ein paar Methoden mit vi.fn() hinklatschen, fertig. Das Argument "es ist ja nur ein Test" hält sich hartnäckig, obwohl es der Realität widerspricht - Testcode wird genauso oft gelesen, refactored und erweitert wie Produktivcode, oft sogar häufiger, weil er bei jeder Änderung am getesteten Modul mitwachsen muss.
Ein zweiter Treiber ist copy-paste: Ein bestehender any-getypter Mock wird für den nächsten Test kopiert, leicht angepasst, und die Typlücke vervielfältigt sich über die gesamte Testsuite. Nach ein paar Monaten hat praktisch jedes Testfile mindestens eine Stelle, an der any im Spiel ist - meist unbemerkt, weil der Test trotzdem grün bleibt. Genau dieser Automatismus ist das eigentliche Problem: Er verhindert nicht, dass Tests fehlschlagen, sondern verhindert, dass sie die richtigen Dinge zum Fehlschlagen bringen.
2. Warum any in Tests genauso riskant ist wie in Produktivcode
Der zentrale Zweck eines Tests ist es, Regressionen im getesteten Code zu erkennen - inklusive Typregressionen. Wird eine Methode in einem Service umbenannt oder ihre Signatur geändert, soll der Compiler und in der Folge der Test das sofort anzeigen. Ein any-getypter Mock hebelt genau diesen Mechanismus aus: Die Methode getTotal wird zu calculateTotal umbenannt, der Mock heißt weiterhin getTotal, TypeScript beschwert sich nicht, weil any jede Prüfung überspringt, und der Test bleibt grün, obwohl er de facto nichts mehr über das reale Interface aussagt.
Das Ergebnis ist ein trügerisches Sicherheitsgefühl: Die Testsuite ist grün, aber sie testet nicht mehr das, was sie zu testen vorgibt. In Produktivcode würde ein Reviewer eine any-Deklaration meist hinterfragen. In Testcode rutscht sie oft unkommentiert durch, weil die Fehlerquelle erst sichtbar wird, wenn ein Bug in Produktion auftaucht, den der Test hätte fangen müssen. Genau deshalb verdient Testcode dieselbe Typdisziplin wie der Code, den er absichert - nicht weniger.
3. Typisierte Mocks mit vi.mocked() und Mocked<T>
Der pragmatischste Einstieg in typisierte Mocks ist vi.mocked() aus Vitest, das Pendant in Jest heißt jest.mocked(). Die Funktion nimmt einen bereits typisierten Import entgegen und gibt ihn mit vollständiger Autovervollständigung für die Mock-Methoden zurück, ohne dass eine manuelle Typannotation nötig ist. Für vollständige Objekt-Mocks bietet Vitest zusätzlich den Utility-Typ Mocked<T>, der aus einem beliebigen Interface ein Objekt erzeugt, dessen Methoden alle als Mock-Funktionen typisiert sind.
Der Unterschied zu einem any-Mock zeigt sich am deutlichsten bei einer Umbenennung: Mit Mocked<CartService> meldet der Compiler sofort einen Fehler, wenn das Mock-Objekt eine Methode enthält, die im echten Interface nicht mehr existiert - vor jedem Testlauf, direkt in der IDE. Das folgende Beispiel zeigt den Unterschied zwischen einem any-Mock, der einen Tippfehler unbemerkt durchlässt, und der typisierten Variante, die denselben Fehler beim Kompilieren aufdeckt.
// BEFORE: any hides every typo and shape mismatch in the mock
const cartServiceMock: any = {
addItem: vi.fn(),
removeItem: vi.fn(),
getTotall: vi.fn(), // typo: "getTotall" is never flagged
};
test("checkout applies discount", () => {
const result = calculateCheckout(cartServiceMock, discountCode);
expect(result.total).toBe(89.99);
});
// AFTER: typed via vi.mocked() and the Mocked<T> utility type
import { vi, test, expect } from "vitest";
import type { Mocked } from "vitest";
import type { CartService } from "../src/cart-service";
const cartServiceMock: Mocked<CartService> = {
addItem: vi.fn(),
removeItem: vi.fn(),
getTotal: vi.fn().mockReturnValue(99.99),
};
test("checkout applies discount", () => {
const result = calculateCheckout(cartServiceMock, discountCode);
expect(cartServiceMock.getTotal).toHaveBeenCalledOnce();
expect(result.total).toBe(89.99);
});
4. satisfies: Tippfehler abfangen, ohne den Typ zu verbreitern
Der satisfies-Operator, seit TypeScript 4.9 verfügbar, löst ein Problem, das weder eine explizite Typannotation noch as Type sauber lösen: Eine Annotation wie const mock: UserRepository = {...} weitet den Typ des Objekts auf UserRepository auf, wodurch spezifische Mock-Details wie konkrete mockResolvedValue-Rückgabewerte für spätere Assertions verloren gehen. as UserRepository unterdrückt die Prüfung dagegen komplett und lässt beliebige Abweichungen unbemerkt durch. satisfies prüft das Objekt gegen den Zieltyp, behält aber den engeren, tatsächlichen Typ des Literals bei.
In der Praxis bedeutet das: Ein Tippfehler in einem Methodennamen wird beim Kompilieren als überzählige Eigenschaft gemeldet, weil TypeScript bei satisfies dieselbe Excess-Property-Prüfung durchführt wie bei einer direkten Zuweisung. Gleichzeitig bleibt die IDE-Autovervollständigung auf dem konkreten Mock-Objekt erhalten, was bei einer reinen Interface-Annotation verloren ginge. Für Testcode ist das die ideale Kombination: Struktur-Prüfung gegen das echte Interface, ohne die Präzision des Mocks zu opfern.
import { vi } from "vitest";
import type { UserRepository } from "../src/user-repository";
// satisfies checks the literal against UserRepository's shape,
// then keeps the narrower mock type for autocompletion and assertions
const userRepositoryMock = {
findById: vi.fn().mockResolvedValue({ id: "u1", email: "a@example.com" }),
findByEmial: vi.fn(), // TS2353: object literal may only specify known properties
save: vi.fn(),
} satisfies UserRepository;
// userRepositoryMock.findById is still known as a specific Mock instance here,
// not widened to the generic method signature from UserRepository
userRepositoryMock.findById.mockResolvedValueOnce(null);
5. Typisierte Mock-Factories statt wiederholtem Boilerplate
Typisierte Mocks pro Testfall manuell auszuschreiben führt schnell zu Wiederholung, besonders wenn ein Service viele Methoden hat, von denen ein Test nur eine einzige tatsächlich braucht. Eine typisierte Mock-Factory löst das: Eine Funktion erzeugt ein vollständig typisiertes Standard-Mock-Objekt mit sinnvollen Default-Implementierungen und erlaubt gezielte Überschreibungen über Partial<Mocked<T>>. Der Aufrufer schreibt nur die für den jeweiligen Test relevanten Methoden aus, der Rest bleibt typsicher und funktioniert als No-Op-Mock.
Der entscheidende Vorteil gegenüber copy-paste-Mocks: Ändert sich das Interface, muss nur die Factory-Funktion angepasst werden, nicht jeder einzelne Test. Der Compiler markiert sofort, wenn ein Override nicht mehr zum Interface passt. Diese Struktur reduziert Boilerplate spürbar, ohne auf Typsicherheit zu verzichten - ein Argument, das im "aber any ist doch schneller"-Einwand oft übersehen wird.
import { vi } from "vitest";
import type { Mocked } from "vitest";
interface PricingEngine {
calculate(sku: string, qty: number): number;
applyTax(amount: number): number;
}
// Typed mock factory: sensible defaults, targeted overrides, no repetition
function createPricingEngineMock(
overrides: Partial<Mocked<PricingEngine>> = {},
): Mocked<PricingEngine> {
return {
calculate: vi.fn().mockReturnValue(0),
applyTax: vi.fn().mockReturnValue(0),
...overrides,
};
}
test("applies tax after calculating the base price", () => {
const pricingEngine = createPricingEngineMock({
calculate: vi.fn().mockReturnValue(100),
});
const total = checkoutTotal(pricingEngine, "SKU-1", 2);
expect(pricingEngine.calculate).toHaveBeenCalledWith("SKU-1", 2);
expect(total).toBeGreaterThan(100);
});
6. unknown und Type Guards statt any für lose Testdaten
Für lose strukturierte Testdaten - etwa aus JSON.parse() geladene Fixtures, gemockte HTTP-Responses oder Daten aus einer externen Bibliothek ohne Typen - ist unknown der richtige Ausgangstyp, nicht any. Der entscheidende Unterschied: unknown erlaubt keinerlei Zugriff auf Eigenschaften oder Methoden, bevor der Typ nicht explizit eingeschränkt wurde. Diese erzwungene Einschränkung ist kein Hindernis, sondern Dokumentation - sie macht sichtbar, welche Annahme der Test über die Datenstruktur trifft, und zwingt diese Annahme in eine überprüfbare Form.
Eine Type-Guard-Funktion wie isOrderPayload(value): value is OrderPayload übernimmt diese Prüfung explizit und narrowt unknown auf einen konkreten Typ, sobald die Struktur tatsächlich passt. Schlägt die Prüfung fehl, wirft der Test einen aussagekräftigen Fehler, statt später mit einer undefined-Eigenschaft an einer völlig anderen Stelle zu scheitern. Der zusätzliche Code für den Type Guard zahlt sich vor allem dann aus, wenn dieselbe Fixture-Struktur in mehreren Tests wiederverwendet wird.
import { readFileSync } from "node:fs";
// Fixture loaded from disk: the shape is not statically known at this point
function loadFixture(name: string): unknown {
return JSON.parse(readFileSync(`./fixtures/${name}.json`, "utf-8"));
}
interface OrderPayload {
orderId: string;
items: Array<{ sku: string; qty: number }>;
}
function isOrderPayload(value: unknown): value is OrderPayload {
return (
typeof value === "object" &&
value !== null &&
"orderId" in value &&
typeof (value as { orderId: unknown }).orderId === "string" &&
Array.isArray((value as { items: unknown }).items)
);
}
test("processes a valid order fixture", () => {
const raw = loadFixture("order-valid");
if (!isOrderPayload(raw)) {
throw new Error("Fixture order-valid.json does not match OrderPayload");
}
// raw is narrowed to OrderPayload from here on, no cast needed
const result = processOrder(raw);
expect(result.itemCount).toBe(raw.items.length);
});
7. Laufzeitvalidierung mit zod für API-Response-Fixtures
Type Guards sind für einfache Strukturen ausreichend, werden aber schnell unübersichtlich, sobald verschachtelte Objekte, Arrays und optionale Felder ins Spiel kommen - genau der Fall bei realistischen API-Response-Fixtures. Hier zahlt sich zod aus: Ein Schema beschreibt die erwartete Struktur deklarativ, schema.parse() validiert einen unknown-Wert zur Laufzeit und wirft bei Abweichungen einen präzisen Fehler mit Pfadangabe zur fehlerhaften Eigenschaft. Über z.infer<typeof schema> lässt sich der TypeScript-Typ direkt aus dem Schema ableiten, ohne ihn doppelt zu pflegen.
Der praktische Mehrwert gegenüber einem reinen Type Guard: zod prüft nicht nur Struktur, sondern auch Constraints wie Enum-Werte, positive Zahlen oder Mindestlängen - Dinge, die ein handgeschriebener Guard oft übersieht. In Tests eingesetzt, deckt eine zod-Validierung auf, wenn eine gemockte API-Response vom tatsächlichen Vertrag der echten API abweicht, statt diese Diskrepanz stillschweigend als any durchzuwinken und erst in Produktion zu entdecken.
import { z } from "zod";
import { test, expect } from "vitest";
const ApiOrderResponseSchema = z.object({
orderId: z.string(),
status: z.enum(["pending", "shipped", "delivered"]),
items: z.array(
z.object({ sku: z.string(), qty: z.number().int().positive() }),
),
});
type ApiOrderResponse = z.infer<typeof ApiOrderResponseSchema>;
test("parses a mocked API response for order status", async () => {
const response: unknown = await fetchOrderStatusMock("order-42");
// parse() throws a readable error if the mocked payload drifts
// from the schema, catching contract mismatches before production does
const order: ApiOrderResponse = ApiOrderResponseSchema.parse(response);
expect(order.status).toBe("shipped");
});
8. Wann ein eng begrenztes any im Test noch pragmatisch ist
Typisierte Mocks sind der Regelfall, aber nicht jede Situation rechtfertigt den vollen Aufwand. Ein eng begrenztes, einzeiliges any mit erklärendem Kommentar bleibt pragmatisch vertretbar, wenn zum Beispiel absichtlich ein fehlerhaft geformtes Objekt an eine Funktion übergeben wird, um deren Fehlerbehandlung zu testen, oder wenn eine ungetypte Drittanbieter-Bibliothek eine Grenze markiert, an der TypeScript ohnehin keine Garantien mehr geben kann. In solchen Fällen dokumentiert der Kommentar die bewusste Entscheidung und begrenzt den Blast-Radius auf eine einzige Zeile.
Zum Smell wird any, sobald es zur Standardlösung für "ich habe gerade keine Lust, den Typ herauszufinden" wird - über viele Dateien verteilt, unkommentiert, oft mit // eslint-disable-next-line ohne Begründung davor. Die ESLint-Regel @typescript-eslint/no-explicit-any mit gezielten, kommentierten Ausnahmen macht diesen Unterschied messbar: Ein Team, das any konsequent verbietet und nur mit Begründung erlaubt, hat eine grundsätzlich andere Fehlerkultur als eines, das any stillschweigend toleriert.
9. any vs. typisierte Patterns im direkten Vergleich
Die folgende Übersicht stellt fünf typische Situationen aus dem Testalltag gegenüber - einmal mit any gelöst, einmal mit dem passenden typisierten Pattern.
| Szenario | Mit any | Typisiert | Vorteil |
|---|---|---|---|
| Mock für Service-Interface | const mock: any = {...} |
const mock: Mocked<Service> |
Fehlende/falsche Methoden fallen beim Kompilieren auf |
| Partial-Mock mit Tippfehler | { getTotall: vi.fn() } as any |
{...} satisfies Partial<Service> |
Tippfehler erzeugt Compile-Fehler statt stillen Bug |
| Geparste JSON-Fixture | const data: any = JSON.parse(...) |
unknown + Type Guard |
Zugriff erzwingt explizite Prüfung der Struktur |
| Gemockte API-Response | return {...} as any |
Schema.parse(response) mit zod |
Laufzeitprüfung deckt Drift zur echten API auf |
| Fehler-Objekt im catch-Block | catch (e: any) |
catch (e: unknown) + instanceof |
Verhindert Zugriff auf nicht existente Properties |
In allen fünf Fällen ist der Mehraufwand für die typisierte Variante gering - meist eine zusätzliche Zeile Code oder ein Utility-Typ aus Vitest beziehungsweise Jest. Der Gewinn dagegen ist strukturell: Der Compiler und, wo nötig, eine Laufzeitprüfung mit zod übernehmen die Kontrolle, die bei any komplett beim Menschen liegt.
Mironsoft
Typsichere TypeScript-Testsuiten für Magento-Frontends und Headless-Integrationen
Testsuite ohne any-Löcher gesucht?
Wir analysieren eure bestehenden TypeScript-Tests, identifizieren any-Stellen mit echtem Risiko und ersetzen sie durch typisierte Mocks, satisfies-Patterns und zod-Validierung - ohne dass eure Testsuite dabei langsamer oder umständlicher wird.
Typsicherheits-Audit
any-Vorkommen in Tests systematisch erfassen und nach Risiko priorisieren
Mock-Refactoring
vi.mocked, satisfies und typisierte Mock-Factories einführen
CI-Integration
tsc --noEmit und no-explicit-any als Gate in eurer Pipeline verankern
10. Zusammenfassung
any in Tests zu vermeiden löst kein Stilproblem, sondern ein strukturelles: Tests, deren Mocks keiner Typprüfung unterliegen, können Regressionen im getesteten Code nicht mehr zuverlässig erkennen - selbst wenn sie grün bleiben. vi.mocked() und der Utility-Typ Mocked<T> liefern typisierte Mocks nahezu ohne zusätzlichen Aufwand gegenüber einem any-Mock. satisfies schließt die Lücke zwischen einer zu strikten Typannotation und einem zu laxen as-Cast, indem es Struktur prüft, aber die Präzision des konkreten Objekts erhält.
Für lose strukturierte Daten wie geparste JSON-Fixtures oder gemockte API-Responses ersetzt unknown in Kombination mit Type Guards oder zod-Schemata das pauschale any durch eine explizite, dokumentierte Annahme über die Datenstruktur. Ein eng begrenztes any mit Kommentar bleibt in Ausnahmefällen legitim - problematisch wird es erst, wenn es zur unreflektierten Standardlösung für jede Unsicherheit im Testcode wird.
any in Tests vermeiden - Das Wichtigste auf einen Blick
Typisierte Mocks
vi.mocked() und Mocked<T> ersetzen any-Mocks fast ohne Mehraufwand und decken Interface-Änderungen beim Kompilieren auf.
satisfies statt as
Prüft Mock-Objekte gegen das echte Interface, behält aber die konkreten Typen für Assertions und Autovervollständigung.
unknown + Guards
Erzwingt eine explizite, dokumentierte Prüfung für geparste JSON-Fixtures, statt stillschweigend any zu unterstellen.
zod für API-Fixtures
Validiert gemockte Responses zur Laufzeit gegen ein deklaratives Schema und deckt Drift zur echten API auf.