Fix #62: E2E-Tests an den Typeahead im Kontoblatt angepasst

Zwölf Tests standen auf Rot, ohne dass die Anwendung etwas falsch machte – die
Specs prüften den Stand vor dem Umbau. Vier Ursachen lagen darunter:

Die Kombobox lässt ihr <select> zwar im Formular stehen, setzt es aber auf
hidden; selectOption() wartet darauf vergeblich. Der neue Helfer
tests/e2e/specs/hilfen.js bedient das Eingabefeld, tippt die Beschriftung ein
und klickt den genau passenden Treffer an – exakt, weil [Depot] sonst auch das
[Depot] … des Wertpapiergeschäfts träfe.

Seit dem Schnellspeichern ohne Neuladen stehen zwei Erfolgsmeldungen
übereinander; gemeint ist die neue, also .last().

Die Adressliste ist seit der Adressverwaltung eine Bearbeitungstabelle: Die
Namen stehen in Eingabefeldern, wo toContainText nicht hinsieht. Geprüft wird
jetzt der Feldwert – gespeichert wurde die Adresse die ganze Zeit korrekt.

Die beiden übrigen Fehlschläge waren Folgefehler: Playwright startet nach jedem
Fehlschlag einen frischen Worker, das Spec-Modul wird neu geladen, seine Marke
neu gezogen und beforeAll legt leere Konten an. Sie brauchten keine Änderung.

87 Tests grün, und ohne die Timeouts in 2,7 statt 7,1 Minuten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQ57wQWZ4xKHcKmp26bzgr
This commit is contained in:
fds2610-bot 2026-09-04 12:39:29 +02:00
parent b74cbe5416
commit 5491bb69f6
7 changed files with 99 additions and 20 deletions

View file

@ -1,5 +1,33 @@
# Änderungen # Änderungen
## E2E-Tests an den Typeahead im Kontoblatt angepasst (04.09.2026)
Zwölf E2E-Tests standen seit dem Typeahead-Umbau (Fix #41) auf Rot – nicht, weil die
Anwendung etwas falsch machte, sondern weil die Specs den alten Stand prüften. Vier
Ursachen lagen darunter:
- **Die Kombobox versteckt ihr `<select>`.** `kombobox.js` lässt das ursprüngliche Feld
zwar im Formular stehen, setzt es aber auf `hidden`; `selectOption()` wartet darauf
vergeblich, bis der Test nach 30 Sekunden abbricht. Der neue Helfer
`tests/e2e/specs/hilfen.js` bedient stattdessen das Eingabefeld: Er tippt die
Beschriftung ein – das ist zugleich die Probe auf die Filterung – und klickt den genau
passenden Treffer an. Exakt muss der Vergleich sein, weil `[Depot]` sonst auch das
`[Depot] …` des Wertpapiergeschäfts träfe.
- **Zwei Erfolgsmeldungen übereinander.** Seit dem Schnellspeichern ohne Neuladen
(Fix #40) bleibt die Meldung des Anlegens stehen und die neue kommt darunter. Die Tests
meinen die neue und greifen jetzt zu `.last()`.
- **Die Adressliste ist eine Bearbeitungstabelle.** Seit Fix #51 stehen die Namen in
Eingabefeldern, wo `toContainText` nicht hinsieht. Geprüft wird nun der Feldwert – die
Adresse selbst wurde die ganze Zeit korrekt gespeichert.
- **Zwei Folgefehler.** Playwright startet nach jedem Fehlschlag einen frischen Worker;
damit wird das Spec-Modul neu geladen, seine Marke neu gezogen und `beforeAll` legt neue,
leere Konten an. Die Tests „Kontoblatt lässt sich durchsuchen“ und „Löschen fragt nach“
suchten deshalb eine Buchung, die auf dem Konto des vorigen Workers lag. Sie brauchten
keine Änderung.
Die Suite läuft wieder vollständig durch: 87 Tests grün, und ohne die Timeouts in 2,7
statt 7,1 Minuten.
## FinTS-Bibliothek auf den aktuellen Upstream-Stand gehoben (04.09.2026) ## FinTS-Bibliothek auf den aktuellen Upstream-Stand gehoben (04.09.2026)
Der Fork `nemiah/php-fints` steht jetzt auf `v4.1.4` und damit auf dem Upstream-Stand vom Der Fork `nemiah/php-fints` steht jetzt auf `v4.1.4` und damit auf dem Upstream-Stand vom

View file

@ -51,6 +51,20 @@ Hierher gehört nur, was einen Browser braucht: Formularabläufe, Redirects,
gerenderte Views, JavaScript. Rechen- und Model-Logik gehört in die PHP-Suite gerenderte Views, JavaScript. Rechen- und Model-Logik gehört in die PHP-Suite
(`tests/NN_*.php`) – schneller und isoliert. (`tests/NN_*.php`) – schneller und isoliert.
## Gemeinsame Helfer
`specs/hilfen.js` liegt neben den Specs, endet aber nicht auf `.spec.js` und wird
deshalb nicht als Testdatei eingesammelt. Dort steht `komboboxWaehlen()`: Die
Typeahead-Felder aus `public/static/kombobox.js` lassen ihr `<select>` versteckt
im Formular stehen, `selectOption()` läuft darauf in einen Timeout. Der Helfer
tippt stattdessen die Beschriftung ein und klickt den genau passenden Treffer an.
```js
const { komboboxWaehlen } = require('./hilfen')
await komboboxWaehlen(zeile.locator('#zuordnungsauswahl select'), 'Lebensmittel')
```
## Berichte ## Berichte
HTML-Report, Screenshots und Traces landen nach einem Lauf in HTML-Report, Screenshots und Traces landen nach einem Lauf in

View file

@ -1,5 +1,6 @@
// @ts-check // @ts-check
const { test, expect } = require('@playwright/test') const { test, expect } = require('@playwright/test')
const { komboboxWaehlen } = require('./hilfen')
/* /*
* Der Abgleich mit dem Bargeld-Tool im Browser: die Kachel in der Verwaltung, * Der Abgleich mit dem Bargeld-Tool im Browser: die Kachel in der Verwaltung,
@ -167,11 +168,12 @@ test.describe('Bargeld-Tool', () => {
const zeile = page.locator('tr', { hasText: empfaenger }) const zeile = page.locator('tr', { hasText: empfaenger })
await zeile.locator('a.zuordnungsfeld').click() await zeile.locator('a.zuordnungsfeld').click()
await zeile.locator('#zuordnungsauswahl select').selectOption({ label: KATEGORIE }) await komboboxWaehlen(zeile.locator('#zuordnungsauswahl select'), KATEGORIE)
// Die Zuordnung steht, der gescheiterte Abruf hat sie nicht aufgehalten. // Die Zuordnung steht, der gescheiterte Abruf hat sie nicht aufgehalten.
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung gespeichert') // Über der Meldung steht noch die des Anlegens – gemeint ist die neue.
await expect(page.locator('.meldung-erfolg')).toContainText('fehlgeschlagen') await expect(page.locator('.meldung-erfolg').last()).toContainText('Zuordnung gespeichert')
await expect(page.locator('.meldung-erfolg').last()).toContainText('fehlgeschlagen')
const danach = page.locator('tr', { hasText: empfaenger }) const danach = page.locator('tr', { hasText: empfaenger })
await expect(danach).toContainText(KATEGORIE) await expect(danach).toContainText(KATEGORIE)

View file

@ -1,5 +1,6 @@
// @ts-check // @ts-check
const { test, expect } = require('@playwright/test') const { test, expect } = require('@playwright/test')
const { komboboxWaehlen } = require('./hilfen')
const BENUTZER = process.env.E2E_USER || 'e2e' const BENUTZER = process.env.E2E_USER || 'e2e'
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
@ -172,11 +173,15 @@ test.describe('Buchungen', () => {
await zeile.locator('a.zuordnungsfeld').click() await zeile.locator('a.zuordnungsfeld').click()
// Die Auswahlliste gibt es einmal und wandert in die angeklickte Zelle. // Die Auswahlliste gibt es einmal und wandert in die angeklickte Zelle.
const liste = zeile.locator('#zuordnungsauswahl select') // Sichtbar wird das Formular; das <select> darin bleibt hinter der
// Kombobox verborgen.
const liste = zeile.locator('#zuordnungsauswahl')
await expect(liste).toBeVisible() await expect(liste).toBeVisible()
await liste.selectOption({ label: KATEGORIE }) await komboboxWaehlen(liste.locator('select'), KATEGORIE)
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung') // Die Meldung des Anlegens steht noch da: Das Schnellspeichern lädt
// die Seite nicht neu, sondern hängt seine eigene darunter.
await expect(page.locator('.meldung-erfolg').last()).toContainText('Zuordnung')
await expect(page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` })) await expect(page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` }))
.toContainText(KATEGORIE) .toContainText(KATEGORIE)
}) })
@ -202,9 +207,9 @@ test.describe('Buchungen', () => {
const zeile = page.locator('tr', { hasText: `Kein Neuladen ${MARKE}` }) const zeile = page.locator('tr', { hasText: `Kein Neuladen ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click() await zeile.locator('a.zuordnungsfeld').click()
await zeile.locator('#zuordnungsauswahl select').selectOption({ label: KATEGORIE }) await komboboxWaehlen(zeile.locator('#zuordnungsauswahl select'), KATEGORIE)
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung') await expect(page.locator('.meldung-erfolg').last()).toContainText('Zuordnung')
await expect(zeile).toContainText(KATEGORIE) await expect(zeile).toContainText(KATEGORIE)
// Ein echtes Neuladen hätte sowohl die Markierung gelöscht als auch die // Ein echtes Neuladen hätte sowohl die Markierung gelöscht als auch die
@ -229,7 +234,7 @@ test.describe('Buchungen', () => {
await expect(liste).toBeVisible() await expect(liste).toBeVisible()
await liste.selectOption({ label: KLASSE }) await liste.selectOption({ label: KLASSE })
await expect(page.locator('.meldung-erfolg')).toContainText('Klasse gespeichert') await expect(page.locator('.meldung-erfolg').last()).toContainText('Klasse gespeichert')
await expect(page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` })) await expect(page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` }))
.toContainText(KLASSE) .toContainText(KLASSE)
@ -238,7 +243,7 @@ test.describe('Buchungen', () => {
await danach.locator('a.klassenfeld').click() await danach.locator('a.klassenfeld').click()
await danach.locator('#klassenauswahl select').selectOption('') await danach.locator('#klassenauswahl select').selectOption('')
await expect(page.locator('.meldung-erfolg')).toContainText('Klasse gelöscht') await expect(page.locator('.meldung-erfolg').last()).toContainText('Klasse gelöscht')
await expect(page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` })) await expect(page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` }))
.not.toContainText(KLASSE) .not.toContainText(KLASSE)
}) })
@ -253,9 +258,9 @@ test.describe('Buchungen', () => {
const zeile = page.locator('tr', { hasText: `Nachträglich ${MARKE}` }) const zeile = page.locator('tr', { hasText: `Nachträglich ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click() await zeile.locator('a.zuordnungsfeld').click()
await zeile.locator('#zuordnungsauswahl select').selectOption({ label: `[${SPAR}]` }) await komboboxWaehlen(zeile.locator('#zuordnungsauswahl select'), `[${SPAR}]`)
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung') await expect(page.locator('.meldung-erfolg').last()).toContainText('Zuordnung')
await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` })) await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` }))
.toContainText(`[${SPAR}]`) .toContainText(`[${SPAR}]`)
@ -275,7 +280,7 @@ test.describe('Buchungen', () => {
const zeile = page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }) const zeile = page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click() await zeile.locator('a.zuordnungsfeld').click()
await zeile.locator('#zuordnungsauswahl select').selectOption('split') await komboboxWaehlen(zeile.locator('#zuordnungsauswahl select'), 'Splitbuchung …')
const dialog = page.locator('#splitdialog') const dialog = page.locator('#splitdialog')
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
@ -292,7 +297,7 @@ test.describe('Buchungen', () => {
// Der Dialog hat zwei Submit-Knöpfe: Speichern und Löschen. // Der Dialog hat zwei Submit-Knöpfe: Speichern und Löschen.
await dialog.locator('button[type="submit"].primaer').click() await dialog.locator('button[type="submit"].primaer').click()
await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung') await expect(page.locator('.meldung-erfolg').last()).toContainText('Splitbuchung')
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first()) await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first())
.toContainText('Splitbuchung') .toContainText('Splitbuchung')
@ -320,19 +325,19 @@ test.describe('Buchungen', () => {
const dialog = page.locator('#splitdialog') const dialog = page.locator('#splitdialog')
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
await expect(page.locator('#zuordnungsauswahl select')).toBeHidden() await expect(page.locator('#zuordnungsauswahl')).toBeHidden()
await expect(dialog.locator('input[name="split[0][betrag]"]')).toHaveValue('-30,00') await expect(dialog.locator('input[name="split[0][betrag]"]')).toHaveValue('-30,00')
await dialog.locator('#splitloeschen').click() await dialog.locator('#splitloeschen').click()
await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung gelöscht') await expect(page.locator('.meldung-erfolg').last()).toContainText('Splitbuchung gelöscht')
const danach = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` }) const danach = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` })
await expect(danach).not.toContainText('Splitbuchung') await expect(danach).not.toContainText('Splitbuchung')
await expect(danach).not.toContainText(KATEGORIE) await expect(danach).not.toContainText(KATEGORIE)
// Und die Zuordnung geht jetzt wieder über die Auswahlliste. // Und die Zuordnung geht jetzt wieder über die Auswahlliste.
await danach.locator('a.zuordnungsfeld').click() await danach.locator('a.zuordnungsfeld').click()
await expect(danach.locator('#zuordnungsauswahl select')).toBeVisible() await expect(danach.locator('#zuordnungsauswahl')).toBeVisible()
}) })
test('Splitbuchung mit zwei Zeilen', async ({ page }) => { test('Splitbuchung mit zwei Zeilen', async ({ page }) => {

26
tests/e2e/specs/hilfen.js Normal file
View file

@ -0,0 +1,26 @@
// @ts-check
/* Gemeinsame Helfer der E2E-Specs. Die Datei endet nicht auf `.spec.js` und
* wird deshalb nicht als Testdatei eingesammelt. */
/**
* Wählt in einer Typeahead-Kombobox (public/static/kombobox.js) den Eintrag
* mit genau dieser Beschriftung.
*
* Seit dem Umbau steht das ursprüngliche `<select>` nur noch versteckt im
* Formular; bedient wird das Eingabefeld daneben. `selectOption()` läuft
* darauf in einen Timeout. Der Helfer tippt die Beschriftung ein – das ist
* zugleich die Probe auf die Filterung – und klickt den Treffer an.
*
* @param {import('@playwright/test').Locator} select das versteckte <select>
* @param {string} beschriftung Text des Eintrags, exakt
*/
async function komboboxWaehlen(select, beschriftung) {
const kombobox = select.locator('xpath=ancestor::div[contains(@class,"kombobox")][1]')
await kombobox.locator('.kombobox-eingabe').fill(beschriftung)
// Exakt, sonst träfe „[Depot]“ auch das „[Depot] …“ des Wertpapiergeschäfts.
await kombobox.getByRole('option', { name: beschriftung, exact: true }).click()
}
module.exports = { komboboxWaehlen }

View file

@ -323,8 +323,11 @@ test.describe('Module der Phasen 4 bis 7', () => {
await expect(page.locator('.meldung-erfolg')).toContainText('erfasst') await expect(page.locator('.meldung-erfolg')).toContainText('erfasst')
// Die Adresse landet wirklich im Adressbuch – nicht nur der Auftrag. // Die Adresse landet wirklich im Adressbuch – nicht nur der Auftrag.
// Die Liste ist eine Bearbeitungstabelle: Die Namen stehen in
// Eingabefeldern, nicht als Text.
await page.goto('/vertraege/adressen') await page.goto('/vertraege/adressen')
await expect(page.locator('table.liste')).toContainText(empfaenger) await expect(page.locator(`table.liste input[name="name"][value="${empfaenger}"]`))
.toHaveCount(1)
}) })
test('Die Empfängerauswahl im Zahlungsverkehr ist ein Typeahead aus dem Adressbuch', async ({ page }) => { test('Die Empfängerauswahl im Zahlungsverkehr ist ein Typeahead aus dem Adressbuch', async ({ page }) => {

View file

@ -1,5 +1,6 @@
// @ts-check // @ts-check
const { test, expect } = require('@playwright/test') const { test, expect } = require('@playwright/test')
const { komboboxWaehlen } = require('./hilfen')
/* /*
* Der Weg, den die Wertpapierabrechnung nimmt: Sie kommt als Buchung aufs * Der Weg, den die Wertpapierabrechnung nimmt: Sie kommt als Buchung aufs
@ -107,7 +108,7 @@ test.describe('Wertpapiergeschäft aus dem Kontoblatt', () => {
const zeile = page.locator('tr', { hasText: empfaenger }) const zeile = page.locator('tr', { hasText: empfaenger })
await zeile.locator('a.zuordnungsfeld').click() await zeile.locator('a.zuordnungsfeld').click()
await zeile.locator('#zuordnungsauswahl select').selectOption({ label: `[${DEPOT}] …` }) await komboboxWaehlen(zeile.locator('#zuordnungsauswahl select'), `[${DEPOT}] …`)
const dialog = page.locator('#wertpapierdialog') const dialog = page.locator('#wertpapierdialog')
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
@ -166,7 +167,7 @@ test.describe('Wertpapiergeschäft aus dem Kontoblatt', () => {
const zeile = page.locator('tr', { hasText: empfaenger }) const zeile = page.locator('tr', { hasText: empfaenger })
await zeile.locator('a.zuordnungsfeld').click() await zeile.locator('a.zuordnungsfeld').click()
await zeile.locator('#zuordnungsauswahl select').selectOption({ label: `[${DEPOT}] …` }) await komboboxWaehlen(zeile.locator('#zuordnungsauswahl select'), `[${DEPOT}] …`)
await expect(page.locator('#wertpapierdialog')).toBeVisible() await expect(page.locator('#wertpapierdialog')).toBeVisible()
await page.fill('#wp_name', `Sparplan-Fonds ${MARKE}`) await page.fill('#wp_name', `Sparplan-Fonds ${MARKE}`)