Fixtures, die mit dem Domain-Type mitwachsen
Wer Testdaten als lose getippte Objektliterale schreibt, riskiert stillen Verfall, sobald sich der zugrunde liegende Domain-Type ändert und ein neues Pflichtfeld entsteht. Typsichere Factory-Funktionen mit Partial-T und sinnvollen Defaults zentralisieren Testdaten an einer Stelle, erzwingen Synchronität mit dem echten Interface und halten Vitest-Suiten über hunderte Testdateien hinweg wartbar und konsistent.
Inhaltsverzeichnis
- 1. Warum rohe Objektliterale in Tests stillschweigend verrotten
- 2. Das Fundament: Factory-Funktionen statt Objektliteralen
- 3. Partial<T> und sinnvolle Defaults für überschreibbare Fixtures
- 4. Fixture-Drift verhindern: TypeScript erzwingt Synchronität mit dem Domain-Type
- 5. Deep-Partial-Factories für verschachtelte Objekte
- 6. Factory-Komposition: Factories rufen andere Factories auf
- 7. Integration mit Vitest: Factories in Testdateien nutzen
- 8. Erweiterte Patterns: Sequenzen, Traits und Builder-Overrides
- 9. Objektliterale gegen Factory-Pattern im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum rohe Objektliterale in Tests stillschweigend verrotten
In wachsenden TypeScript-Codebasen ändert sich das User-Interface fast jeden Sprint: ein neues Pflichtfeld für Mandantenfähigkeit, ein zusätzliches Flag für Zwei-Faktor-Authentifizierung, ein umbenanntes Property nach einem Refactoring. Wer Testdaten als rohe Objektliterale schreibt, oft mit as User typisiert oder ganz ohne Typannotation, merkt von diesen Änderungen häufig nichts. Der Compiler prüft eine as-Assertion nicht strukturell gegen das Ziel-Interface, sondern akzeptiert sie als Zusicherung des Entwicklers, solange die Typen grob kompatibel bleiben. Das Fixture bleibt kompilierbar, obwohl es das reale Domain-Modell längst nicht mehr korrekt abbildet.
Die Folgen zeigen sich selten sofort. Tests bleiben grün, weil sie nur die Felder prüfen, die vor der Änderung relevant waren. Erst wenn eine neue Business-Regel genau das fehlende Feld auswertet, etwa eine Berechtigungsprüfung auf Basis von tenantId, produziert die Testsuite falsch positive Ergebnisse: Sie bestätigt Code, der in Produktion mit echten Daten anders reagieren würde. Diese Lücke zwischen Fixture und Domain-Type ist der Kern des Problems, das typsichere Factory-Funktionen strukturell lösen sollen.
2. Das Fundament: Factory-Funktionen statt Objektliteralen
Eine Factory-Funktion ersetzt das verstreute Objektliteral durch eine einzige Funktion, die ein vollständiges, valides Objekt des Domain-Types zurückgibt. Der Rückgabetyp der Funktion ist explizit der echte Typ, zum Beispiel User, nicht any und nicht as User. Damit prüft der TypeScript-Compiler bei jeder Änderung am Interface automatisch, ob die Factory noch ein vollständiges Objekt liefert. Fehlt ein Pflichtfeld, schlägt die Kompilierung an genau einer Stelle fehl, nicht verstreut über Dutzende Testdateien.
Der zweite Baustein ist ein Parameter vom Typ Partial<T>, über den einzelne Felder pro Testfall überschrieben werden können, während alle übrigen Felder sinnvolle, realistische Defaults behalten. Das Ergebnis ist ein Aufruf im Testfall, der nur die Felder zeigt, die für den jeweiligen Test tatsächlich relevant sind, während der Rest unsichtbar, aber typkorrekt im Hintergrund bleibt.
// Domain type: the single source of truth for a User
interface User {
id: string;
email: string;
displayName: string;
role: 'customer' | 'admin' | 'guest';
createdAt: Date;
isEmailVerified: boolean;
}
// WRONG: raw object literal cast with "as User"
// Compiles today, but silently rots when User gains a new field
const legacyFixture = {
id: 'usr_1',
email: 'jane@example.com',
} as User;
// RIGHT: a typed factory function is the single place that must
// stay in sync with the User interface
function createUser(overrides: Partial<User> = {}): User {
return {
id: 'usr_' + Math.random().toString(36).slice(2, 10),
email: 'jane.doe@example.com',
displayName: 'Jane Doe',
role: 'customer',
createdAt: new Date('2026-01-01T00:00:00Z'),
isEmailVerified: true,
...overrides,
};
}
// Usage in a test: only the relevant field is visible
const adminUser = createUser({ role: 'admin' });
3. Partial<T> und sinnvolle Defaults für überschreibbare Fixtures
Partial<T> markiert im Overrides-Parameter jedes Feld als optional, ohne den Rückgabetyp der Factory selbst zu verwässern. Das ist ein entscheidender Unterschied zu einem verbreiteten Antipattern: die Factory selbst mit einem Partial<User>-Rückgabetyp zu deklarieren. Dann müsste jeder Konsument der Factory plötzlich mit optionalen Feldern umgehen, und genau die Typsicherheit, die man eigentlich gewinnen wollte, ginge wieder verloren. Der Trick liegt darin, Partial<T> ausschließlich für den Input zu nutzen und via Object-Spread mit konkreten Defaultwerten zu einem vollständigen T zusammenzuführen.
Die Wahl der Default-Werte verdient Sorgfalt. Leere Strings oder 0 als Default verschleiern oft, welches Feld ein Test tatsächlich überschrieben hat, und erschweren das Debugging fehlgeschlagener Assertions. Bewährt haben sich sprechende, realistische Defaults wie 'Jane Doe' statt '', feste Zeitstempel statt new Date() für deterministische Tests, sowie klar erkennbare ID-Präfixe wie usr_, die in Fehlermeldungen sofort verraten, aus welcher Factory ein Objekt stammt.
4. Fixture-Drift verhindern: TypeScript erzwingt Synchronität mit dem Domain-Type
Der eigentliche Wert der Factory zeigt sich, sobald sich der Domain-Type ändert. Bekommt User ein neues Pflichtfeld wie tenantId: string, meldet der TypeScript-Compiler einen Fehler exakt an der Stelle, an der createUser das Rückgabeobjekt zusammenbaut, weil dort ein Pflichtfeld fehlt. Diese eine Fehlermeldung ersetzt das manuelle Durchsuchen von Dutzenden oder Hunderten Testdateien nach veralteten Objektliteralen. Voraussetzung dafür ist strict: true in der tsconfig.json, insbesondere strictNullChecks, da sonst fehlende Felder unter Umständen stillschweigend als undefined durchgehen.
Wichtig ist, den Rückgabetyp der Factory niemals mit as zu erzwingen und niemals any im Overrides-Parameter zu verwenden. Beide Praktiken schalten exakt die Prüfung ab, die Fixture-Drift verhindern soll. Ebenso sollte die Factory keine importierten, veralteten Typ-Duplikate verwenden, sondern immer denselben User-Typ importieren, der auch in der Produktionslogik verwendet wird, damit Interface und Fixture strukturell nie auseinanderlaufen können.
// Domain type gains a new required field after a migration
interface User {
id: string;
email: string;
displayName: string;
role: 'customer' | 'admin' | 'guest';
createdAt: Date;
isEmailVerified: boolean;
tenantId: string; // new required field
}
// Compile error: Property 'tenantId' is missing in type
// '{ id: string; email: string; ... }' but required in type 'User'.
function createUser(overrides: Partial<User> = {}): User {
return {
id: 'usr_' + Math.random().toString(36).slice(2, 10),
email: 'jane.doe@example.com',
displayName: 'Jane Doe',
role: 'customer',
createdAt: new Date('2026-01-01T00:00:00Z'),
isEmailVerified: true,
// tenantId missing here triggers TS2741 at this exact line
...overrides,
};
}
// Fix: add the new field once, at the single source of truth
function createUserFixed(overrides: Partial<User> = {}): User {
return {
id: 'usr_' + Math.random().toString(36).slice(2, 10),
email: 'jane.doe@example.com',
displayName: 'Jane Doe',
role: 'customer',
createdAt: new Date('2026-01-01T00:00:00Z'),
isEmailVerified: true,
tenantId: 'tenant_default',
...overrides,
};
}
5. Deep-Partial-Factories für verschachtelte Objekte
Partial<T> macht nur die oberste Ebene eines Objekts optional. Bei verschachtelten Domain-Types wie einer Order mit eingebetteter Address reicht das nicht: Partial<Order> erlaubt zwar, shippingAddress komplett wegzulassen, verlangt aber, sobald man es angibt, wieder das vollständige Address-Objekt. Ein Test, der nur die Postleitzahl überschreiben will, müsste sonst die komplette Adresse erneut ausschreiben. Ein rekursiver DeepPartial<T>-Hilfstyp löst das, indem er jede verschachtelte Objektebene ebenfalls optional macht, ohne die Typsicherheit der Blattfelder zu verlieren.
In der Praxis kombiniert man DeepPartial<T> häufig mit gezielten Sub-Factories statt einem einfachen Object-Spread, weil Spread nur eine Ebene tief zusammenführt und verschachtelte Objekte komplett ersetzt statt zu mischen. Für die meisten Testfälle genügt es, verschachtelte Objekte über eigene Factories wie createAddress aufzulösen und diese gezielt in der übergeordneten Factory zu verwenden, was Komplexität reduziert, ohne auf generische Deep-Merge-Bibliotheken angewiesen zu sein.
interface Address {
street: string;
city: string;
postalCode: string;
country: string;
}
// Generic helper: makes every nested level optional, not just the top one
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
function createAddress(overrides: DeepPartial<Address> = {}): Address {
return {
street: 'Musterstrasse 12',
city: 'Berlin',
postalCode: '10115',
country: 'DE',
...overrides,
};
}
// Test only cares about the postal code, everything else stays default
const berlinWithNewZip = createAddress({ postalCode: '10999' });
// A shallow Partial<Address> would demand the full object once any
// field is provided for a nested structure; DeepPartial avoids that
// for factories composed from several nested sub-factories.
6. Factory-Komposition: Factories rufen andere Factories auf
Ein Order-Objekt besteht aus einem User, einer Liste von OrderItem-Einträgen und einer Address. Statt diese Struktur in jeder Order-Factory erneut von Grund auf zu bauen, ruft createOrder die bereits existierenden Factories createUser, createOrderItem und createAddress auf und überschreibt bei Bedarf nur einzelne Zweige über overrides. Diese Komposition spiegelt exakt die Struktur des Domain-Models wider: Genau wie Order im Produktionscode einen User referenziert, referenziert createOrder im Test createUser.
Der Vorteil zeigt sich besonders bei tief verschachtelten Aggregaten. Ändert sich User, muss ausschließlich createUser angepasst werden, nicht jede Order-, Invoice- oder Shipment-Factory, die intern einen User referenziert. Composition-Factories sollten dabei niemals raten, welches Feld ein Test typischerweise überschreibt, sondern konsequent alle Overrides eins zu eins an die jeweilige Sub-Factory durchreichen, damit auch tief verschachtelte Anpassungen vorhersehbar funktionieren.
interface OrderItem {
sku: string;
quantity: number;
unitPriceCents: number;
}
interface Order {
id: string;
user: User;
items: OrderItem[];
shippingAddress: Address;
status: 'pending' | 'paid' | 'shipped' | 'cancelled';
totalCents: number;
}
function createOrderItem(overrides: Partial<OrderItem> = {}): OrderItem {
return {
sku: 'SKU-1001',
quantity: 1,
unitPriceCents: 1999,
...overrides,
};
}
// Composition: createOrder reuses createUser, createOrderItem and
// createAddress instead of rebuilding their shape from scratch
function createOrder(overrides: Partial<Order> = {}): Order {
return {
id: 'ord_' + Math.random().toString(36).slice(2, 10),
user: createUser(),
items: [createOrderItem()],
shippingAddress: createAddress(),
status: 'pending',
totalCents: 1999,
...overrides,
};
}
// Nested override: swap only the user's role, everything else stays default
const adminOrder = createOrder({ user: createUser({ role: 'admin' }) });
7. Integration mit Vitest: Factories in Testdateien nutzen
In der Praxis liegen Factories in einer eigenen tests/factories.ts-Datei außerhalb des Produktionscodes, aber mit direktem Import des echten Domain-Types. Vitest-Testdateien importieren daraus ausschließlich die Factory-Funktionen, nie rohe Fixture-Objekte. Der Effekt: Jede Testdatei, die createUser oder createOrder verwendet, profitiert automatisch von jeder zukünftigen Korrektur oder Erweiterung der Factory, ohne dass ein einziger Testfall manuell angefasst werden muss. Für Assertions, die auf konkrete Werte prüfen, empfiehlt sich, genau die Felder zu überschreiben, die für die jeweilige Testaussage relevant sind, und den Rest der Factory-Defaults zu überlassen.
Ein zusätzlicher Vorteil im Zusammenspiel mit Vitest: Da Factories reine Funktionen ohne Seiteneffekte sind, lassen sie sich problemlos in beforeEach-Blöcken neu erzeugen, was versehentliche Zustands-Leaks zwischen Testfällen verhindert, die bei wiederverwendeten, gemeinsam mutierten Fixture-Objekten sonst leicht entstehen. Snapshot-Tests profitieren zusätzlich von festen, deterministischen Defaults wie einem fixen createdAt-Zeitstempel, da sonst jeder Testlauf einen neuen Snapshot-Diff erzeugen würde.
import { describe, it, expect, beforeEach } from 'vitest';
import { createUser, createOrder } from './factories';
describe('OrderService.canCancel', () => {
let order: ReturnType<typeof createOrder>;
beforeEach(() => {
// Fresh fixture per test, no shared mutable state
order = createOrder({ status: 'pending' });
});
it('allows cancellation while status is pending', () => {
expect(order.status).toBe('pending');
});
it('blocks cancellation for admin-only shipped orders', () => {
const admin = createUser({ role: 'admin' });
const shipped = createOrder({ user: admin, status: 'shipped' });
expect(shipped.status).toBe('shipped');
expect(shipped.user.role).toBe('admin');
});
});
8. Erweiterte Patterns: Sequenzen, Traits und Builder-Overrides
Für Tests, die eindeutige IDs über viele Aufrufe hinweg brauchen, etwa beim Prüfen von Sortierreihenfolgen, reicht Math.random() als ID-Quelle nicht mehr aus. Ein einfacher modulinterner Zähler, der bei jedem Factory-Aufruf inkrementiert wird, liefert vorhersagbare, aufsteigende IDs wie usr_1, usr_2, usr_3, ganz ohne externe Bibliotheken. Named-Preset-Funktionen wie createAdminUser oder createGuestUser, die intern lediglich createUser mit festen Overrides aufrufen, verbessern zusätzlich die Lesbarkeit an Testaufrufstellen, sollten aber sparsam eingesetzt werden, damit die Zahl der Presets nicht selbst zur unübersichtlichen zweiten Fixture-Quelle wird.
Builder-artige, verkettete Overrides wie UserBuilder.withRole('admin').withVerifiedEmail().build() wirken auf den ersten Blick elegant, verursachen in der Praxis aber mehr Wartungsaufwand als eine einfache Factory-Funktion mit Partial<T>, weil jede neue Methode zusätzlichen Code und zusätzliche Tests für den Builder selbst erfordert. Für die überwiegende Mehrheit der Testfälle bleibt eine einzelne Funktion mit Overrides-Parameter die einfachste, am wenigsten fehleranfällige Lösung, die zudem am direktesten von TypeScripts eingebauter Typprüfung profitiert.
9. Objektliterale gegen Factory-Pattern im direkten Vergleich
Die folgende Übersicht fasst zusammen, wo rohe Objektliterale in Tests typischerweise scheitern und wie das Factory-Pattern mit Partial<T> dasselbe Problem strukturell löst.
| Aufgabe | Rohes Objektliteral | Typsichere Factory | Vorteil |
|---|---|---|---|
| Neues Pflichtfeld im Interface | Bricht still, weil as User nicht strukturell prüft |
Compile-Fehler direkt in der Factory | Fixture-Drift wird unmöglich |
| Testdaten-Duplikation | Objekt in Dutzenden Testdateien dupliziert | Eine zentrale createUser-Funktion | Eine Änderung genügt |
| Lesbarkeit des Testintents | Komplettes Objekt mit irrelevanten Feldern sichtbar | Nur relevante Overrides sichtbar | Testabsicht auf einen Blick |
| Verschachtelte Objekte | Jede Ebene manuell dupliziert | DeepPartial plus Sub-Factories | Kein Merge von Hand |
| Refactoring-Sicherheit | Fehler oft erst zur Laufzeit sichtbar | Fehler zur Kompilierzeit an der Quelle | Sichere, planbare Refactorings |
In der Praxis zeigt sich der Unterschied am deutlichsten bei Refactorings: Ein Team, das ausschließlich Factories verwendet, ändert nach einer Interface-Erweiterung im Schnitt eine einzige Datei, während Objektliterale über die gesamte Testsuite verstreute, oft erst durch fehlschlagende CI-Läufe entdeckte Anpassungen erzwingen.
Mironsoft
TypeScript-Testing, Vitest-Setup und Type-Safety-Beratung für Magento- und Node-Projekte
Testdaten, die nie vom Domain-Type abweichen?
Wir richten typsichere Factory-Patterns, Vitest-Setups und CI-Guards für eure TypeScript-Codebasis ein, von Fixture-Bibliotheken bis zur automatisierten Drift-Erkennung im Build.
Fixture-Audit
Analyse bestehender Testdaten und Migration auf typsichere Factories
Vitest-Setup
Testinfrastruktur, Coverage-Ziele und CI-Integration für TypeScript-Projekte
Type-Safety-Review
Strict-Mode-Migration und Domain-Type-Modellierung für bestehenden Code
10. Zusammenfassung
Typsichere Test-Fixtures und Factories lösen ein Problem, das in wachsenden TypeScript-Codebasen unvermeidlich ist: Domain-Types ändern sich, und rohe Objektliterale merken davon nichts, solange sie über as oder ganz ohne Typprüfung erzeugt werden. Eine Factory-Funktion mit echtem Rückgabetyp zwingt den Compiler, bei jeder Interface-Änderung sofort und an genau einer Stelle zu reagieren. Partial<T> im Overrides-Parameter hält Testfälle knapp und lesbar, während sinnvolle, sprechende Defaults den Rest des Objekts übernehmen.
Für verschachtelte Domain-Types wie Bestellungen mit eingebetteten Adressen und Positionen ergänzt ein DeepPartial<T>-Hilfstyp zusammen mit Sub-Factories das Fundament, ohne auf externe Deep-Merge-Bibliotheken angewiesen zu sein. Factory-Komposition, bei der eine Factory bestehende Factories aufruft, hält die Testdaten strukturell so nah wie möglich am echten Domain-Model. In Vitest integriert, mit frischen Instanzen pro Testfall und deterministischen Defaults, wird aus dem Factory-Pattern die einfachste verfügbare Absicherung gegen stillen Fixture-Verfall.
Typsichere Test-Fixtures und Factories - Das Wichtigste auf einen Blick
Factory statt Literal
createUser(overrides) statt Objektliteral mit as User. Der Compiler prüft die Factory bei jeder Interface-Änderung automatisch.
Partial<T> für Overrides
Nur relevante Felder pro Test überschreiben, alle anderen Felder behalten realistische, sprechende Defaults.
DeepPartial für Verschachtelung
Verschachtelte Objekte wie Adressen oder Order-Items über Sub-Factories und DeepPartial<T> auflösen, statt allein auf Spread zu setzen.
Vitest-Integration
Factories in beforeEach neu erzeugen, feste Zeitstempel für deterministische Snapshots, nie geteilte, mutierte Fixture-Objekte.