Zum Inhalt

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:

  1. Testdaten reinschreiben (Artikel, Kunden, Varianten)
  2. Cloud Function aufrufen (createOrder, Triggers)
  3. Ergebnis aus der DB zurücklesen und prüfen ob alles korrekt ist
  4. 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