Testkonzept: Kritische Geschäftsprozesse¶
Nur das Wesentliche. 5 Testbereiche, die bei Fehler direkte Auswirkungen auf Umsatz, Lieferungen oder Kundenzufriedenheit haben.
Übersicht¶
| # | Bereich | Typ | Datei | Warum |
|---|---|---|---|---|
| 1 | Bestellanlage Shop | Cloud Function (JS) | create-order.test.js |
Falsche Preise = finanzieller Schaden |
| 2 | Bestellanlage ERP | Cloud Function (JS) | create-order.test.js |
Falsche Bestelldaten = Lieferfehler |
| 3 | Automatische Kundenzuweisung | Cloud Function (JS) | article-assignment.test.js |
Kunden sehen falsche Artikel |
| 4 | Liefertermin-Berechnung | Cloud Function (JS) | delivery-date.test.js |
Falsche Liefertermine |
| 5 | Push-Notification Versand | Cloud Function (JS) | push-notification.test.js |
Kunden erhalten keine Benachrichtigungen |
Alles in einer Sprache (JavaScript), alles Cloud Functions, ein npm test Befehl.
Test 1: Bestellanlage aus dem Shop¶
Datei: core/functions/test/unit/callables/create-order.test.js
Der kritischste Pfad: Shop-Benutzer schickt Warenkorb an createOrder Callable. Die Cloud Function muss Preise aus der DB nehmen (nie vom Client), den Counter atomar hochzählen, und ein korrektes Order-Dokument schreiben.
Testfälle¶
| Test | Was passiert | Erwartung |
|---|---|---|
| 1.1 Erfolgreiche Bestellung (Happy Path) | 2 Artikel, explizite Varianten, Mengen 3 und 1 | Order in DB mit: server-validierten Preisen, priceValidatedByServer: true, orderStatus: 0, orderNumber >= 10000, korrektem amount |
| 1.2 Client-Preise werden ignoriert | Client schickt Positionen (nur articleId, variantId, quantity) |
Preise im Order-Dokument = DB-Preise, nicht was der Client gesendet hat |
| 1.3 Keine Variante angegeben → Default | Position ohne variantId |
Nimmt Variante mit isDefault: true, deren DB-Preis |
| 1.4 Keine Default-Variante → erste verfügbare | Alle Varianten ohne isDefault |
Preis der ersten verfügbaren Variante |
| 1.5 Betrags-Berechnung & Rundung | Preis 9.99 × Menge 3 | amount = 29.97 (nicht 29.970000...004), Gesamt = Summe aller Positionen |
| 1.6 Auftragsnummer: Counter existiert nicht | Erster Aufruf | orderNumber = 10000, Counter angelegt |
| 1.7 Auftragsnummer: Counter existiert | Counter steht auf 10042 | orderNumber = 10043 |
| 1.8 Nicht authentifiziert → Fehler | Kein request.auth |
HttpsError("unauthenticated") |
| 1.9 Kein Kundenzugang → Fehler | customerId nicht in JWT-Claims |
HttpsError("permission-denied") |
| 1.10 Blockierter Artikel → Fehler | isBlocked: true |
HttpsError("failed-precondition") |
| 1.11 Nicht verfügbarer Artikel → Fehler | isAvailable: false |
HttpsError("failed-precondition") |
| 1.12 Nicht-verfügbare Variante → Fehler | Variante isAvailable: false |
HttpsError("failed-precondition") |
| 1.13 Leere Positionsliste → Fehler | positions: [] |
HttpsError("invalid-argument") |
Test 2: Bestellanlage aus dem ERP¶
Datei: core/functions/test/unit/callables/create-order.test.js (gleiche Datei wie Test 1)
Warum als Cloud Function Test? Das ERP schreibt aktuell direkt via Firebase SDK in Firestore (kein Callable). Aber die kritische Geschäftslogik — Preisberechnung, Auftragsnummer, Pflichtfelder — muss genauso validiert werden. Deshalb testen wir die createOrder Callable auch mit createdBySource: 2 (ERP), sodass zukünftig beide Wege über die gleiche validierte Pipeline laufen können.
Zusätzlich: Wir testen die ERP-spezifischen Szenarien, die im Shop nicht vorkommen.
Testfälle¶
| Test | Was passiert | Erwartung |
|---|---|---|
| 2.1 ERP-Bestellung (Happy Path) | createdBySource: 2, Kunde + 3 Positionen |
Order mit createdBySource: 2, alle Felder korrekt |
| 2.2 Bestellung mit Rabatt | Position mit rebate > 0 |
amount korrekt berechnet (price × qty - rebate), Gesamt stimmt |
| 2.3 Bestellung mit Lieferadresse | deliveryAddress: { street, city, zipcode, country } |
Adresse korrekt im Order-Dokument gespeichert |
| 2.4 Bestellung ohne Lieferadresse | deliveryAddress: null |
Feld fehlt oder ist null (= Hauptadresse des Kunden) |
| 2.5 Verschiedene OrderSources korrekt | createdBySource: 0 (web), 1 (mobile), 2 (erp) |
Jeweils korrekt im Dokument |
| 2.6 Bestellung mit orderNotes | orderNotes: "Bitte vor 10 Uhr liefern" |
Notes korrekt gespeichert, getrimmt |
Test 3: Automatische Kundenzuweisung¶
Datei: core/functions/test/unit/triggers/article-assignment.test.js
Was wird geprüft: Artikel mit assignmentType: automatic werden den richtigen Kunden zugewiesen. Manuelle Zuweisungen werden nie überschrieben. Kategorie-Counter bleiben konsistent.
Testfälle¶
| Test | Was passiert | Erwartung |
|---|---|---|
| 3.1 Automatischer Artikel erstellt → Matching | Artikel: customerCategoryIds: ["premium"], countryIds: ["0"]. 3 Kunden: premium/DE, standard/DE, premium/AT |
Nur premium/DE-Kunde bekommt customerArticles-Doc mit assignmentType: 1 |
| 3.2 Manueller Artikel → kein Assignment | articleToCustomerAssignmentType: 0 |
Keine customerArticles-Docs erstellt |
| 3.3 Ohne Länder-Filter → alle passenden Kategorien | countryIds: [] |
Alle Kunden mit passender Kategorie, egal welches Land |
| 3.4 Neuer Kunde → bekommt passende Artikel | Kunde mit Kategorie "premium" wird erstellt | Alle automatic-Artikel mit "premium" in customerCategoryIds werden zugewiesen |
| 3.5 Manual → Automatic: Manuelle bleiben erhalten | Kunde A hat assignmentType: 0 (manuell), Artikel wechselt auf automatic |
Kunde A behält assignmentType: 0, Kunde B (neu passend) bekommt assignmentType: 1 |
| 3.6 Automatic → Manual: Automatische werden entfernt | Artikel wechselt auf manual | Alle assignmentType: 1 Docs gelöscht, manuelle (assignmentType: 0) bleiben |
| 3.7 Kategorie-Counter bei Zuweisung | Artikel hat articleCategoryIds: ["brot", "frisch"] |
Kunden-Counter: brot: +1, frisch: +1 |
| 3.8 Kategorie-Counter bei Löschung | Artikel wird gelöscht | Alle betroffenen Kunden: Counter pro Kategorie -1 |
Test 4: Liefertermin-Berechnung¶
Datei: core/functions/test/unit/utils/delivery-date.test.js
Warum wichtig: ~200 Zeilen reine Berechnungslogik mit Oster-Algorithmus, 15 Feiertagen, 4 Schedule-Typen — komplett ungetestet. Dabei kundenrelevant: der berechnete Liefertag steht auf jeder Bestellung.
Vorteil: Pure Funktion, keine DB-Mocks nötig. Einfachster Test mit höchstem ROI.
Testfälle¶
| Test | Szenario | Erwartung |
|---|---|---|
| 4.1 Wöchentlich Di+Do | Schedule: weekly, daysOfWeek: [2,4] |
Nächster Dienstag oder Donnerstag |
| 4.2 Wochentag ist Feiertag → überspringen | Nächster Liefertag fällt auf nicht-konfigurierten Feiertag | Wird übersprungen, nächster gültiger Tag |
| 4.3 Monatlich am 15. | Schedule: monthly, monthlyDay: 15 |
Der 15. (diesen oder nächsten Monat) |
| 4.4 Betriebsurlaub überlappt Liefertag | Urlaub 10.-20., Liefertag wäre 15. | Nächster gültiger Tag nach Urlaub |
| 4.5 Kunden-Lieferpause | deliveryBreaks mit aktiver Pause |
Nächster Tag NACH der Pause |
| 4.6 Kein Schedule → globale Wochentage | schedule: null |
Nächster Mo-Fr (oder was global konfiguriert ist) |
| 4.7 deliveryType = none | deliveryType: 3 |
null |
| 4.8 Custom: Jeder 2. Mittwoch | weekInterval: 2, weekday: 3, referenceDate: ... |
Korrekte 2-Wochen-Berechnung |
| 4.9 Custom: Letzter Freitag im Monat | weekOfMonth: -1, weekday: 5 |
Letzter Freitag des Monats |
| 4.10 Ostersonntag-Berechnung | 2025, 2026, 2027 | 20.04.2025, 05.04.2026, 28.03.2027 |
Test 5: Push-Notification Versand¶
Datei: core/functions/test/unit/triggers/push-notification.test.js
Was wird geprüft: Der gesamte Push-Versand-Pfad: Feed-Entry wird erstellt → Trigger lädt Empfänger + Tokens → FCM wird aufgerufen → Ergebnis wird zurückgeschrieben. Kritisch, weil stille Fehler (z.B. abgelaufene Tokens, falsche Sprachfilter) dazu führen, dass Kunden keine Benachrichtigungen erhalten.
Kern-Dateien:
- core/functions/src/triggers/customer-feed.triggers.js (onCustomerFeedCreated)
- core/functions/src/utils/fcm-utils.js (sendPushToTokens)
- core/functions/src/jobs/instances/job_createNotificationEntries.js
Testfälle¶
| Test | Was passiert | Erwartung |
|---|---|---|
| 5.1 Feed-Entry erstellt → Push wird gesendet | customerFeed-Doc mit type: 0 (push), status: 0 (pending) erstellt. Kunde hat 1 approved User mit 2 Devices |
sendPushToTokens wird mit beiden Tokens aufgerufen, deliveryInfo wird geschrieben mit totalSent: 2 |
| 5.2 Sprachfilter: Nur passende Sprache | Feed-Entry language: 5 (DE). 2 User: einer DE, einer EN |
Nur DE-User bekommt Push, EN-User wird übersprungen |
| 5.3 Sprachfilter: User ohne Sprachpräferenz → bekommt alles | Feed-Entry language: 5. User hat language: null |
User bekommt Push (kein Filter bei null) |
| 5.4 Abgelaufene Tokens → automatisches Cleanup | FCM gibt UNREGISTERED für Token zurück |
Token wird aus customerUsers/{id}/devices gelöscht, Rest wird normal gesendet |
| 5.5 Kein approved User → kein Versand | Kunde hat nur User mit registrationState: 0 (pending) |
Kein FCM-Aufruf, deliveryInfo.totalTokens: 0 |
| 5.6 Notification-Entries werden korrekt erstellt | Notification mit recipientIds: ["c1", "c2"], localizedMessages DE+EN |
Für jeden Kunden: Push-Feed-Entry (type: 0) + In-App-Entry (type: 1) in richtiger Sprache |
| 5.7 Critical Alert: Android + iOS Payload | Feed-Entry mit isCritical: true |
Android: channelId: "critical_alerts", priority: "max". iOS: interruption-level: "time-sensitive", apns-priority: "10" |
| 5.8 Registrierung genehmigt → Push an User | customerUser.registrationState wechselt von 0 → 1 |
Push mit Titel "Freigeschaltet" an diesen User gesendet |
Testansatz: Emulator statt Mocks¶
Warum Emulator?¶
Mit Mocks testest du nur: "Wurde firestore.set() aufgerufen?" — aber nicht ob die Daten wirklich in der DB landen. Das reicht nicht.
Der Firebase Emulator (bereits konfiguriert in firebase.json, Port 8080) gibt uns eine echte lokale Firestore-Instanz. Damit können wir:
- Testdaten reinschreiben (Artikel, Kunden, Varianten)
- Cloud Function aufrufen (createOrder, Triggers)
- Ergebnis aus der DB zurücklesen und prüfen ob alles korrekt ist
- Zwischen Tests die DB leeren (sauberer Zustand)
Ablauf pro Test:
┌─────────────────────────┐
│ 1. Testdaten in │
│ Emulator-Firestore │ Artikel, Kunden, Counter, etc.
│ schreiben │
└──────────┬──────────────┘
│
┌──────────▼──────────────┐
│ 2. Function aufrufen │ createOrder(), Trigger feuert, etc.
│ (gegen Emulator) │
└──────────┬──────────────┘
│
┌──────────▼──────────────┐
│ 3. Ergebnis aus DB │ orders/{id} lesen → Preis prüfen
│ zurücklesen │ customerArticles/{id} → Assignment prüfen
└──────────┬──────────────┘
│
┌──────────▼──────────────┐
│ 4. Assert │ Preise korrekt? Counter stimmt?
│ │ OrderNumber inkrementiert?
└─────────────────────────┘
Konkretes Beispiel: Order-Erstellung prüfen¶
const { initializeApp } = require('firebase-admin/app');
const { getFirestore } = require('firebase-admin/firestore');
// Verbindet sich mit dem lokalen Emulator
process.env.FIRESTORE_EMULATOR_HOST = 'localhost:8080';
const app = initializeApp({ projectId: 'test-project' });
const db = getFirestore(app);
describe('createOrder', () => {
beforeEach(async () => {
// DB leeren (Emulator REST-API)
await fetch('http://localhost:8080/emulator/v1/projects/test-project/databases/(default)/documents', {
method: 'DELETE'
});
});
it('sollte Order mit server-validierten Preisen in DB schreiben', async () => {
// 1. ARRANGE: Testdaten in Emulator-DB schreiben
await db.collection('articles').doc('art-1').set({
number: 'ART-001',
name: 'Testbrot',
isAvailable: true,
isBlocked: false,
showInShop: true,
articleVariants: {
'var-1': { name: '500g', price: 4.99, isAvailable: true, isDefault: true, showInShop: true }
}
});
await db.collection('customers').doc('cust-1').set({
companyName: 'Test GmbH',
customerNumber: 'KUN-001',
country: 0
});
// 2. ACT: createOrder aufrufen
const result = await callCreateOrder({
auth: { uid: 'user-1', token: { customerIds: ['cust-1'] } },
data: {
customerId: 'cust-1',
customerNumber: 'KUN-001',
customerCompanyName: 'Test GmbH',
positions: [{ articleId: 'art-1', variantId: 'var-1', quantity: 3 }]
}
});
// 3. ASSERT: Order aus DB zurücklesen und prüfen
const orderDoc = await db.collection('orders').doc(result.orderId).get();
const order = orderDoc.data();
expect(orderDoc.exists).to.be.true; // ← IST wirklich in der DB
expect(order.orderPositions[0].price).to.equal(4.99); // ← Server-Preis, nicht Client
expect(order.amount).to.equal(14.97); // ← 3 × 4.99
expect(order.priceValidatedByServer).to.be.true;
expect(order.orderNumber).to.be.at.least(10000);
expect(order.orderStatus).to.equal(0); // ← pending
// 4. Counter auch prüfen
const counter = await db.collection('counters').doc('orderCounter').get();
expect(counter.data().value).to.equal(10000);
});
});
Was wird echt, was wird gemockt?¶
| Komponente | Echt (Emulator) | Mock | Warum |
|---|---|---|---|
| Firestore | ✅ | Kern der Tests — wir wollen prüfen ob Daten wirklich in DB landen | |
| Cloud Functions Runtime | ✅ | Functions laufen lokal im Emulator | |
| Firestore Triggers | ✅ | Feuern automatisch im Emulator wenn Daten geschrieben werden | |
| Auth Claims | ✅ | Token-Claims simulieren wir (kein Auth-Emulator nötig) | |
| FCM (Push) | ✅ | Kein externer Dienst im Test — wir mocken admin.messaging() und prüfen ob send() mit korrektem Payload aufgerufen wird |
|
| Cloud Scheduler | ✅ | Geplante Notifications testen wir durch direkten Funktionsaufruf |
Einrichtung¶
firebase.json erweitern (Auth-Emulator + Functions-Emulator hinzufügen):
"emulators": {
"firestore": { "port": 8080 },
"functions": { "port": 5001 },
"auth": { "port": 9099 },
"ui": { "enabled": true }
}
package.json Scripts + Dependencies:
{
"scripts": {
"test": "firebase emulators:exec --only firestore,functions 'mocha --recursive test/**/*.test.js --timeout 30000'",
"test:watch": "mocha --recursive test/**/*.test.js --watch --timeout 30000",
"emulators": "firebase emulators:start --only firestore,functions"
},
"devDependencies": {
"firebase-functions-test": "^3.1.0",
"mocha": "^10.0.0",
"chai": "^4.3.0",
"sinon": "^17.0.0"
}
}
npm test startet den Emulator, führt alle Tests aus, und fährt den Emulator wieder runter. Alles in einem Befehl.
Dateistruktur¶
core/functions/test/
├── helpers/
│ ├── emulator-setup.js ← Emulator-Verbindung + DB-Cleanup
│ └── fcm-mock.js ← Nur FCM mocken (kein Emulator dafür)
└── unit/
├── callables/
│ └── create-order.test.js ← Tests 1 + 2
├── triggers/
│ ├── article-assignment.test.js ← Test 3
│ └── push-notification.test.js ← Test 5
└── utils/
└── delivery-date.test.js ← Test 4 (pure Funktion, kein Emulator nötig)
Test-Helper: Emulator-Setup¶
// test/helpers/emulator-setup.js
const { initializeApp, deleteApp } = require('firebase-admin/app');
const { getFirestore } = require('firebase-admin/firestore');
let app, db;
async function setupEmulator() {
process.env.FIRESTORE_EMULATOR_HOST = 'localhost:8080';
app = initializeApp({ projectId: 'test-project' });
db = getFirestore(app);
return db;
}
async function clearFirestore() {
// Emulator REST-API: alle Daten löschen
await fetch(
`http://${process.env.FIRESTORE_EMULATOR_HOST}/emulator/v1/projects/test-project/databases/(default)/documents`,
{ method: 'DELETE' }
);
}
async function teardownEmulator() {
await deleteApp(app);
}
module.exports = { setupEmulator, clearFirestore, teardownEmulator };
Reihenfolge der Umsetzung¶
| Schritt | Was | Warum zuerst |
|---|---|---|
| 0 | Emulator-Setup + Helper | Grundlage für alle Tests |
| 1 | delivery-date.test.js |
Pure Funktion, kein Emulator nötig, sofort umsetzbar |
| 2 | create-order.test.js |
Kern-Geschäftslogik — hier zeigt der Emulator seinen Wert |
| 3 | article-assignment.test.js |
Trigger feuert automatisch im Emulator |
| 4 | push-notification.test.js |
Emulator + FCM-Mock Kombination |
Was bewusst NICHT getestet wird¶
| Bereich | Grund |
|---|---|
| Model Serialisierung (Dart) | Existierende Unit-Tests decken das ab |
| BLoC State Management (Dart) | 18+ BLoC-Tests vorhanden |
| N-Gram Suche | Nicht geschäftskritisch |
| UI/Widgets | Kein Geschäftslogik-Risiko |
| Rate Limiting | Kein Geschäftsschaden bei Ausfall |
| Kategorie-Counter (separat) | Bereits in Test 3 mit abgedeckt |
| Manuelle Zuweisungen (separat) | Bereits in Test 3 mit abgedeckt |