Refactoring mit Testnetz in Vue: Composables, Pinia und Simple Design
https://bildung.social/@oerinformatik/
https://oer-informatik.de/vue-refactoring-pinia
Vue 3, Refactoring, Composables, Pinia, Store, Simple Design, Code Smells, Extreme Programming, Charakterisierungstest, Vitest, TypeScript
tl/dr; (ca. 32 min Lesezeit): Bevor die Web-App des Mitfahrlabors eine Anmeldung bekommt, räumt das Team auf. Der Artikel zeigt Refactoring als Praktik aus Extreme Programming: Code Smells im eigenen Code finden, das Testnetz prüfen und mit einem Charakterisierungstest flicken, dann in kleinen Schritten umbauen, nach denen alle Tests grün sind. Doppelte Ladezustände wandern in ein Composable, die gewählte Testperson in einen Store mit Pinia. Unterwegs findet das Team einen Fehler und behebt ihn erst danach, mit dem anderen Hut. Die vier Regeln von Simple Design entscheiden, was in den Store gehört und was nicht [AI-Assisted]. (Zuletzt geändert am 11.11.2026)
Dieser Artikel ist Teil einer Artikelreihe über die Web-App mit Vue 3 und TypeScript: Projektübersicht / XP-Projektstart / TypeScript für Java-Entwickler*innen / Komponenten testgetrieben / API-Client aus OpenAPI / Router und Continuous Integration / Formulare und Validierung / Refactoring mit Pinia / Anmeldung mit Keycloak / Fahrer-Sicht und Coding Standards / Build und Auslieferung / End-to-End-Tests mit Playwright / Change Request Live-Aktualisierung
Schon fit in diesem Thema? Nutze die Kann-Liste und die Leitfragen unter dem Artikel als Checkliste, ob Du die relevanten Kompetenzen bereits hast.
Dieser Artikel baut auf der Kann-Liste des Artikels Formulare und Validierung in Vue auf.
Als Grundlagen zu Refactoring, Composables und Pinia kann ich nach Durchlesen des Artikels…
… erklären, was Refactoring ist, warum es in Extreme Programming zu jeder Aufgabe gehört und warum ich dabei nie gleichzeitig das Verhalten ändere (zwei Hüte).
… die vier Regeln von Simple Design nennen und mit ihnen begründen, ob ein Umbau den Code einfacher macht.
… Code Smells wie doppelten Code, geteilten veränderlichen Zustand und spekulative Allgemeinheit in einer Vue-App erkennen.
… beschreiben, was ein Composable ist und wie es sich von einer reinen Hilfsfunktion und von einem Store unterscheidet.
… einen Store in Pinia mit State, Gettern und Actions lesen und angeben, wann ich
storeToRefs()brauche.
Darüber hinaus kann ich als Expertenwissen zu Refactoring, Composables und Pinia…
… das Testnetz vor einem Umbau prüfen und Lücken mit einem Charakterisierungstest schließen.
… einen Umbau in kleine Schritte zerlegen, nach denen jeweils alle Tests grün sind, und einen gefundenen Fehler getrennt davon test-first beheben.
… ein Composable mit
watchschreiben und ohne Komponente in einemeffectScope()testen, auch für die Reihenfolge verspäteter Antworten.… Komponenten, die einen Store nutzen, so testen, dass jeder Test ein frisches Pinia bekommt.
Im Artikel Formulare und Validierung in Vue hat die Web-App des Mitfahrlabors zwei Formulare bekommen, und Thomas Becker hat Iteration 3 abgenommen. Die App funktioniert, 73 Tests sind grün. Trotzdem zögert das Paar, das die Anmeldung (WEB-08) übernehmen will: Die gewählte Testperson steckt in einer Datei, die drei Komponenten direkt importieren, und Detailseite und Suche verwalten ihren Ladezustand jeweils auf eigene Art. Wer die Anmeldung einbaut, müsste an all diesen Stellen eingreifen. Das Team räumt deshalb zuerst auf. Die Grafik zeigt den Stand vorher und nachher:
Stand im Projekt: Beginn von Iteration 4
Für Iteration 4 hat Thomas in der Kundenstunde wie im Releaseplan drei Karten gewählt, zusammen 5 Punkte:
Spike Anmeldung mit Keycloak (1 Punkt): ein zeitlich begrenzter Versuch, um WEB-08 schätzen zu können.
WEB-08 Mit dem Schulkonto anmelden (2 Punkte): Die App erfährt aus der Anmeldung, wer sie benutzt.
WEB-09 Anfragen bestätigen oder ablehnen (2 Punkte): Fahrer*innen entscheiden über die Anfragen zu ihren Fahrten.
Eine Karte „Aufräumen“ gibt es nicht. In Extreme Programming (XP) ist Refactoring keine eigene Story, die die Kund*in bezahlt oder ablehnen könnte, sondern Teil jeder Aufgabe: Das Team räumt auf, bevor oder nachdem es eine Funktion einbaut, so wie man vor dem Kochen die Arbeitsfläche frei macht. Im Planning Game zerlegt das Paar die Karten in Aufgaben und stellt zwei davon nach vorn:
| Aufgabe | gehört zu | Grund |
|---|---|---|
| Testnetz prüfen, Lücken mit Tests schließen | vor allen Karten | Ohne Tests ist ein Umbau kein Refactoring, sondern ein Risiko. |
| Testperson an eine Stelle bringen | WEB-08 | Die Anmeldung soll nur eine Stelle ändern, nicht drei Komponenten. |
| Ladezustände vereinheitlichen | WEB-08, WEB-09 | WEB-09 bekommt eine weitere Seite, die lädt. |
| Spike Keycloak | Spike | Der Code des Spikes wird danach weggeworfen. |
| Anmeldung, Bestätigen und Ablehnen | WEB-08, WEB-09 | eigentliche Funktionen |
In stories.md stand nach Iteration 3 als Plan: „Die Testperson aus sitzung.ts und die Ladezustände der Seiten wandern in einen gemeinsamen Store.“ Ob das so bleibt, entscheidet sich in diesem Artikel, und es bleibt nicht ganz so.
Refactoring als Praktik in Extreme Programming
Martin Fowler definiert Refactoring als Änderung der inneren Struktur von Software, die sie leichter verständlich und billiger änderbar macht, ohne ihr beobachtbares Verhalten zu ändern. Für die Nutzenden des Mitfahrlabors ändert sich nach diesem Artikel also nichts; Thomas würde in der Kundenstunde keinen Unterschied sehen. Was Refactoring allgemein ausmacht, welche Code Smells es gibt und wie die klassischen Umbauten heißen, erklärt der Artikel Refactoring: Code Smells erkennen und in sicheren Schritten umbauen der Clean-Code-Reihe. Hier geht es darum, wie das in einem Vue-Projekt nach XP aussieht.
In XP greift Refactoring in drei andere Praktiken ein:
Testgetriebene Entwicklung: Im Kreislauf Red – Green – Refactor ist das Aufräumen der dritte Schritt jedes Zyklus. Die Tests aus den vorigen Artikeln sind das Sicherheitsnetz, das größere Umbauten erst erlaubt.
Simple Design: Der Code soll zu jedem Zeitpunkt so einfach sein, wie es die heutigen Anforderungen zulassen. Weil das Team nicht für morgen vorbaut, muss es morgen umbauen können.
Gemeinsame Verantwortung für den Code (Collective Code Ownership): Jedes Paar darf jede Stelle verbessern. Das geht nur, wenn eine verbesserte Stelle nichts kaputt macht, was ein anderes Paar gebaut hat.
Kent Beck hat für die Trennung von Umbau und Funktion das Bild der zwei Hüte geprägt: Mit dem einen Hut fügst Du Verhalten hinzu oder änderst es, mit dem anderen baust Du um. Du darfst schnell wechseln, aber nie beide gleichzeitig tragen. Wird während eines Refactorings ein Test rot, weißt Du dann sicher, dass der letzte Umbauschritt falsch war.
Simple Design: vier Regeln als Maßstab
Was „einfacher“ heißt, legt Kent Beck in vier Regeln fest, die nach Vorrang geordnet sind:
| Regel | Bedeutung | Frage für die Web-App |
|---|---|---|
| 1. Besteht alle Tests | Der Code tut, was er soll, und das ist belegt. | Sind nach jedem Schritt alle Tests grün? |
| 2. Drückt die Absicht aus | Namen und Aufbau sagen, was der Code tut. | Sieht man einer Seite an, dass sie lädt und für wen? |
| 3. Keine Doppelungen | Jedes Wissen steht an genau einer Stelle (DRY). | Steht die Logik „laden, warten, Ergebnis anzeigen“ zweimal da? |
| 4. Möglichst wenige Elemente | keine Klassen, Funktionen oder Felder ohne heutigen Zweck | Braucht der Store ein Feld, das heute niemand liest? |
Die vierte Regel ist das Gegengewicht zur dritten: Wer Doppelungen um jeden Preis beseitigt, baut schnell Abstraktionen, die niemand braucht. Der Artikel Einfach bleiben: KISS, YAGNI, DRY vertieft diesen Zielkonflikt; YAGNI (You Ain’t Gonna Need It) ist dieselbe Idee wie Regel 4.
Code Smells im Stand 06
Ein Code Smell ist ein Hinweis im Code, dass sich ein Umbau lohnen könnte, kein Fehler im engeren Sinn. Das Paar geht den Stand 06 durch und findet drei Stellen, die riechen:
Doppelter Code in den Ladezuständen. Die Detailseite AngebotSeite.vue lädt ein Angebot so:
const ergebnis = ref<AngebotErgebnis>()
watch(
() => props.angebotId,
async (id) => {
ergebnis.value = undefined
ergebnis.value = await ladeAngebot(id)
},
{ immediate: true },
)Die Suche FahrtenSuche.vue macht dasselbe mit einem eigenen Merker laedt:
const filter = ref<AngebotFilter>({})
const ergebnis = ref<Suchergebnis>()
const laedt = ref(false)
watch(
filter,
async (neuerFilter) => {
laedt.value = true
ergebnis.value = await sucheFahrten(props.quelle, neuerFilter)
laedt.value = false
},
{ immediate: true },
)Beide Stellen tragen dasselbe Wissen: Wenn sich ein Auslöser ändert, lade neu, merke Dir, dass geladen wird, und lege das Ergebnis ab. Sie drücken es nur unterschiedlich aus. WEB-09 bringt eine dritte Seite, die genauso lädt.
Geteilter veränderlicher Zustand auf Modulebene. src/domain/sitzung.ts exportiert aktuellePersonId = ref(3). Drei Komponenten importieren ihn und lesen oder schreiben ihn direkt. Das war im letzten Artikel bewusst die einfachste Lösung, die funktioniert. Inzwischen zeigt sich der Preis an einem Test in navigation.spec.ts:
it('KT-05.2 in der App: Mit Funkenflug verschwindet auf Angebot 1 der Button „Platz anfragen“', async () => {
aktuellePersonId.value = 3
const { wrapper } = await starteApp('/angebote/1')
// …
await wrapper.get('#testperson').setValue('1')
expect(aktuellePersonId.value).toBe(1)
expect(wrapper.find('.platzanfrage').exists()).toBe(false)
aktuellePersonId.value = 3
})Die erste und die letzte Zeile setzen den Zustand von Hand zurück. Ein Modul wird pro Testdatei nur einmal geladen, also teilen sich alle Tests einer Datei diesen einen ref. Vergisst jemand das Zurücksetzen, hängt ein Test vom vorherigen ab. Das ist ein Test Smell, der auf den Smell im Code zeigt: Zustand, den viele brauchen, hat keinen klaren Besitzer.
Spekulative Allgemeinheit im Plan. Der dritte Fund steht nicht im Code, sondern in stories.md: „Ladezustände der Seiten wandern in einen gemeinsamen Store.“ Ob eine Fahrt gerade lädt, interessiert aber nur die Seite, die sie lädt. Ein Store für alle Ladezustände wäre ein Element ohne heutigen Zweck (Regel 4). Das Paar streicht diesen Teil des Plans, bevor er Code wird.
| Smell | Stelle | Umbau |
|---|---|---|
| doppelter Code (Duplicated Code) | AngebotSeite.vue, FahrtenSuche.vue: ref + watch für das Laden |
Logik in ein Composable herauslösen (Extract Function) |
| geteilter veränderlicher Zustand (Global Data, Mutable Data) | aktuellePersonId in sitzung.ts, drei direkte Importe, Zurücksetzen im Test |
Zustand in einen Store mit klarem Besitzer verschieben (Encapsulate Variable) |
| spekulative Allgemeinheit (Speculative Generality) | Plan „alle Ladezustände in den Store“ | nicht bauen |
Das Testnetz prüfen und flicken
Refactoring braucht Tests, die das heutige Verhalten festhalten. Bevor das Paar die erste Zeile umbaut, prüft es drei Dinge:
npm testist grün (73 Tests in 16 Dateien), ebensonpm run lintundnpm run build. Auf rotem Grund lässt sich nicht erkennen, ob ein Umbau etwas kaputt macht.Für jede Stelle, die sich ändert, gibt es Tests, die durch sie hindurchlaufen:
AngebotSeite.api.spec.tslädt Angebote, auch nach einem Wechsel des Parameters;FahrtenSuche.spec.tsprüft Laden, Treffer, Leermeldung und Fehler;navigation.spec.tswechselt die Testperson auf der Detailseite.Die Tests prüfen Verhalten, nicht Aufbau: Sie suchen Texte, Rollen und Attribute im DOM, nicht die Namen interner Variablen. Nur ein Test liest
aktuellePersonIddirekt; er wird sich beim Umbau ändern müssen.
Bei Punkt 2 fällt eine Lücke auf. Kein Test prüft, dass „Fahrt anbieten“ die gewählte Testperson als fahrerId mitschickt. AngebotFormular.api.spec.ts übergibt die ID als Prop; dass die Seite AngebotNeuSeite.vue sie aus der Sitzung holt, sichert niemand ab. Genau diese Verbindung wird sich beim Umbau ändern.
Das Paar schließt die Lücke mit einem Charakterisierungstest: einem Test, der festhält, was der Code heute tut, ohne zu bewerten, ob das richtig ist. Er läuft über die ganze App, wählt „Funkenflug“ und fängt den Body der Anfrage im nachgebildeten Backend ab:
it('schickt ein neues Angebot mit der ID der gewählten Testperson', async () => {
let fahrerId: unknown
server.use(
http.post(`${API}/angebote`, async ({ request }) => {
const body = (await request.json()) as { fahrerId: number }
fahrerId = body.fahrerId
return HttpResponse.json({ ...body, id: 8, status: 'offen' }, { status: 201 })
}),
)
const { wrapper } = await starteApp('/angebote/neu')
await wrapper.get('#testperson').setValue('1')
await wrapper.get('#haltepunkt').setValue('1')
await wrapper.get('#richtung-schule').setValue()
await wrapper.get('#datum').setValue('2099-01-05')
await wrapper.get('#abfahrt').setValue('07:00')
await wrapper.get('#plaetze').setValue('2')
await wrapper.get('#kosten').setValue('1.80')
await wrapper.get('form').trigger('submit')
await vi.waitFor(() => expect(fahrerId).toBe(1))
})Das Datum liegt absichtlich weit in der Zukunft, damit die Prüfung „nicht vor heute“ den Test nicht in einigen Wochen rot werden lässt. Der Test ist im Stand 06 sofort grün, wie es sich für einen Charakterisierungstest gehört. Er ist der erste Commit dieses Artikels, noch bevor sich irgendetwas am Code ändert.
Composables: Logik mit Zustand wiederverwenden
Für den doppelten Ladecode bietet Vue ein eigenes Werkzeug. Ein Composable ist eine Funktion, die die Composition API von Vue (ref, computed, watch, Lebenszyklus-Hooks) nutzt, um Logik mit Zustand zu kapseln, und die reaktive Werte zurückgibt. Composables heißen nach Konvention use… und werden in <script setup> bzw. setup() einer Komponente aufgerufen. Jeder Aufruf erzeugt eigenen Zustand: Zwei Seiten, die dasselbe Composable nutzen, teilen sich nichts.
Abgrenzen lässt sich das gegen zwei Nachbarn. Eine reine Hilfsfunktion wie filterAngebote() oder ladeAngebot() kennt keine Reaktivität; sie bekommt Werte und gibt Werte zurück. Ein Composable dagegen verbindet sich mit dem Lebenszyklus der Komponente, etwa über ein watch, das Vue beim Abbau der Komponente selbst beendet. Mixins, die ältere Lösung aus Vue 2, verstecken dagegen, woher ein Feld kommt, und kollidieren bei gleichen Namen. In Vue 3 ersetzen Composables sie.
Das Paar löst den gemeinsamen Teil heraus. Der Auslöser (Routenparameter oder Filter) und die Ladefunktion werden Parameter, ergebnis und laedt der Rückgabewert:
import { ref, shallowRef, watch, type Ref, type ShallowRef, type WatchSource } from 'vue'
export interface Ladezustand<T> {
ergebnis: ShallowRef<T | undefined>
laedt: Ref<boolean>
}
export function useLaden<A, T>(ausloeser: WatchSource<A>, laden: (wert: A) => Promise<T>): Ladezustand<T> {
const ergebnis = shallowRef<T>()
const laedt = ref(false)
watch(
ausloeser,
async (wert) => {
laedt.value = true
ergebnis.value = await laden(wert)
laedt.value = false
},
{ immediate: true },
)
return { ergebnis, laedt }
}Drei Entscheidungen stecken darin. WatchSource<A> erlaubt als Auslöser einen ref (der Filter) wie auch eine Getter-Funktion (() => props.angebotId), genau wie watch selbst. shallowRef statt ref reicht, weil das Ergebnis immer als Ganzes ersetzt und nie im Inneren verändert wird; Vue muss dann nicht jedes verschachtelte Feld beobachten. Und useLaden() fängt keine Fehler ab: Beide Ladefunktionen liefern schon heute Ergebnistypen mit art: 'fehler', statt eine Ausnahme zu werfen. Ein try/catch im Composable wäre ein Element ohne heutigen Zweck.
Der Umbau der beiden Komponenten geht in zwei getrennten Schritten, nach jedem laufen alle Tests. In der Detailseite schrumpfen ref, watch und das Leeren des Ergebnisses auf eine Zeile:
const { ergebnis, laedt } = useLaden(() => props.angebotId, (id) => ladeAngebot(id))Im Template ersetzt v-if="laedt || !ergebnis" die Bedingung v-if="!ergebnis", denn das Ergebnis bleibt jetzt während des Nachladens stehen, statt geleert zu werden. Für die Nutzenden ist das gleich: In beiden Fassungen steht „Fahrt wird geladen …“, bis die neue Fahrt da ist; der Test „lädt neu, wenn sich nur der Parameter ändert“ bleibt grün. In der Suche bleibt nur:
const filter = ref<AngebotFilter>({})
const { ergebnis, laedt } = useLaden(filter, (aktuellerFilter) => sucheFahrten(props.quelle, aktuellerFilter))Das Template der Suche bleibt unverändert, weil es laedt und ergebnis schon so benutzt hat. Damit sind die Komponenten wieder bei ihrer Aufgabe: anzeigen. Ein eigener Test für useLaden() kommt im nächsten Abschnitt dazu, mit einem Grund.
Zwei Hüte: die verspätete Antwort
Beim Herauslösen bemerkt das Paar etwas, das in beiden alten Fassungen steckte und jetzt an einer Stelle sichtbar wird: Jede Änderung des Auslösers startet eine neue Anfrage, aber niemand achtet auf die Reihenfolge der Antworten. Klickt Kaffeetasse in der Suche auf Angebot 1, ändert dann schnell die Adresse auf Angebot 4, und antwortet das Backend auf die erste Anfrage langsamer als auf die zweite, überschreibt die verspätete Antwort das richtige Ergebnis. Die Adresse zeigt /angebote/4, die Seite Angebot 1.
Das ist ein Fehler, also eine Verhaltensänderung, kein Refactoring. Das Paar setzt den Hut ab: Es beendet den laufenden Umbauschritt, lässt die Tests laufen (grün), committet und notiert den Fehler. Erst dann wechselt es den Hut und behebt ihn test-first.
Der Test braucht keine Komponente. Ein Composable mit watch muss nur innerhalb eines Effekt-Bereichs laufen; in einer Komponente ist das setup(), im Test reicht effectScope(). Für die Reihenfolge der Antworten baut sich der Test Promises, die er selbst auflöst:
function steuerbar<T>() {
let erfuellen!: (wert: T) => void
const promise = new Promise<T>((resolve) => (erfuellen = resolve))
return { promise, erfuellen }
}
function inBereich<T>(fn: () => T): T {
return effectScope().run(fn)!
}
it('verwirft eine ältere Antwort, die nach der neueren ankommt', async () => {
const id = ref(1)
const antworten = new Map([
[1, steuerbar<string>()],
[4, steuerbar<string>()],
])
const { ergebnis, laedt } = inBereich(() => useLaden(id, (wert) => antworten.get(wert)!.promise))
id.value = 4
await nextTick() // watch startet die zweite Anfrage
antworten.get(4)!.erfuellen('Angebot 4')
await antworten.get(4)!.promise
await nextTick()
antworten.get(1)!.erfuellen('Angebot 1') // die langsame erste Antwort kommt zuletzt
await antworten.get(1)!.promise
await nextTick()
expect(ergebnis.value).toBe('Angebot 4')
expect(laedt.value).toBe(false)
})Mit der Fassung von oben ist der Test rot: ergebnis enthält „Angebot 1“. Die Korrektur zählt die Anfragen mit und legt eine Antwort nur ab, wenn seitdem keine neuere gestartet wurde:
let letzteAnfrage = 0
watch(
ausloeser,
async (wert) => {
const anfrage = ++letzteAnfrage
laedt.value = true
const antwort = await laden(wert)
if (anfrage !== letzteAnfrage) return // inzwischen wurde neu geladen
ergebnis.value = antwort
laedt.value = false
},
{ immediate: true },
)Der Test wird grün, die übrigen bleiben grün. Zwei weitere Tests in useLaden.spec.ts halten das Grundverhalten fest: Gleich nach dem Aufruf ist laedt wahr und ergebnis leer, und eine Änderung des Auslösers lädt neu. Weil der Fehler jetzt an einer Stelle behoben ist, gilt die Korrektur für Detailseite und Suche zugleich, und für jede künftige Seite, die useLaden() nutzt. Auch das ist ein Gewinn der Regel 3. Im CHANGELOG.md steht der Fehler unter „Behoben“, der Umbau unter „Geändert“.
Pinia: ein Store für gemeinsamen Zustand
Für die Testperson hilft ein Composable nicht: Jeder Aufruf hätte seine eigene Person, die Auswahl oben in App.vue würde die Detailseite nicht erreichen. Gesucht ist das Gegenteil, ein Zustand, den alle Komponenten gemeinsam haben, mit einem klaren Ort, an dem er wohnt. Das ist ein Store: ein zentraler Behälter für anwendungsweiten Zustand, den Komponenten lesen und über festgelegte Funktionen ändern.
Pinia ist die offizielle Bibliothek für Stores in Vue 3 und löst das ältere Vuex ab. Ein Store in Pinia besteht aus drei Teilen:
| Teil | Zweck | in der Setup-Schreibweise | entspricht in einer Komponente |
|---|---|---|---|
| State | der gespeicherte Zustand | ref() |
ref() |
| Getter | aus dem State abgeleitete Werte | computed() |
computed() |
| Action | Funktionen, die den State ändern, auch asynchron | function |
Methode |
Gegenüber einem ref auf Modulebene, wie ihn sitzung.ts hatte, bringt Pinia drei Dinge mit. Der Store gehört zu einer Pinia-Instanz, die die App mit app.use(createPinia()) einbindet; ein Test kann sich jederzeit eine frische Instanz nehmen. Die Vue DevTools zeigen alle Stores mit ihrem Zustand und jeder Änderung an. Und der Store hat einen Namen und einen festen Ort, an dem man nachsieht, wer ihn ändert.
Pinia kennt zwei Schreibweisen. Die Options-Schreibweise beschreibt State, Getter und Actions als Objekt:
export const useSitzungStore = defineStore('sitzung', {
state: () => ({ personId: 3 }),
})Die Setup-Schreibweise nutzt dieselben Bausteine wie <script setup>: eine Funktion, die ref, computed und Funktionen anlegt und zurückgibt. Das Team nimmt sie, weil die übrige App überall die Composition API nutzt. Was die Funktion zurückgibt, ist öffentlich; Pinia erkennt am Typ, was State (ref), Getter (computed) und Action (Funktion) ist. Wichtig: Jeder State muss zurückgegeben werden, sonst sehen ihn weder DevTools noch Tests.
Das Paar installiert Pinia (Version 4) mit npm install pinia und bindet es in src/main.ts ein:
import { createPinia } from 'pinia'
// …
createApp(App).use(createPinia()).use(erzeugeRouter()).mount('#app')Die Testperson zieht in den Store
Der Store entsteht in src/stores/sitzung.ts. Er enthält genau das, was sitzung.ts bisher hatte, nicht mehr:
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const testpersonen = [
{ id: 1, pseudonym: 'Funkenflug' },
{ id: 3, pseudonym: 'Kaffeetasse' },
{ id: 4, pseudonym: 'Bassline' },
{ id: 7, pseudonym: 'Ostwind' },
{ id: 8, pseudonym: 'Rotkehlchen' },
] as const
export const useSitzungStore = defineStore('sitzung', () => {
/** ID der Testperson (Vorgabe: 3 = Kaffeetasse) */
const personId = ref<number>(3)
return { personId }
})Ein Getter pseudonym oder eine Action wechsleZu() wären naheliegend, aber heute liest niemand das Pseudonym aus dem Store, und die Auswahlliste setzt die ID direkt. Pinia erlaubt, den State von außen zu ändern; eine Action lohnt sich erst, wenn eine Änderung mehr ist als eine Zuweisung. Mit WEB-08 wird das so sein, dann bekommt der Store eine Action für die Anmeldung. Regel 4 sagt: erst dann.
Das Paar baut in kleinen Schritten um, nach jedem laufen alle Tests:
Store anlegen, Pinia in
main.tseinbinden,sitzung.tsbleibt vorerst. Ein eigener Testsitzung.spec.tsprüft die Vorgabe 3 und dassuseSitzungStore()überall dieselbe Instanz liefert. Grün.App.vueumstellen:const sitzung = useSitzungStore()undv-model.number="sitzung.personId". Jetzt werdenApp.spec.tsundnavigation.spec.tsrot, mit einer klaren Meldung:Error: […]: "getActivePinia()" was called but there was no active Pinia. Are you trying to use a store before calling "app.use(pinia)"?Die Tests montieren die App ohne Pinia. Die Korrektur gehört zum selben Schritt:
mount(App, { global: { plugins: [createPinia(), router] } }). Weil jeder Test so ein frisches Pinia bekommt, startet jeder bei Kaffeetasse; das Zurücksetzen von Hand im Test zu KT-05.2 entfällt, ebenso der Blick auf die interne Variable. Der Test prüft nur noch, was man sieht: Nach dem Wechsel auf Funkenflug ist der Button weg.AngebotSeite.vueundAngebotNeuSeite.vueumstellen (:mitfahrer-id="sitzung.personId"bzw.:fahrer-id="sitzung.personId"). Der Komponententest der Detailseite bekommt inbeforeEachein frisches Pinia mitsetActivePinia(createPinia()). Der Charakterisierungstest aus dem ersten Commit bleibt unverändert und bleibt grün: Das Angebot trägt weiterhin die ID der gewählten Person.src/domain/sitzung.tslöschen. Die Typprüfung (npm run build) bestätigt, dass niemand die Datei mehr importiert.
Hat ein Refactoring die Tests geändert? Ja, an zwei Stellen, und das ist ehrlich zu benennen. Wo Tests nur die Technik der Einbindung betreffen (Pinia als Plugin), ist das unvermeidlich. Wo ein Test eine interne Variable las, war er zu eng an den Aufbau gebunden; nach dem Umbau prüft er Verhalten und ist damit besser. Die Tests, die Verhalten über die Oberfläche prüfen, darunter alle Kundentests, sind unverändert geblieben. Daran misst sich, ob das Verhalten wirklich gleich geblieben ist.
Beim Lesen aus einem Store lauert eine Falle. Ein Store ist ein reaktives Objekt; wer ihn zerlegt, nimmt die Werte zum Zeitpunkt des Zerlegens mit:
const sitzung = useSitzungStore()
const { personId } = sitzung // falsch: eine Zahl, ändert sich nie mehr
const { personId: id } = storeToRefs(sitzung) // richtig: ein Ref, der dem Store folgtstoreToRefs() macht aus State und Gettern einzelne Refs; Actions kann man direkt herausnehmen, weil Funktionen sich nicht ändern. Das Mitfahrlabor greift über sitzung.personId zu und braucht storeToRefs() deshalb nicht.
Composable, Store oder Props?
Mit dem Umbau hat das Team drei Wege, Zustand zu einer Komponente zu bringen. Welcher passt, entscheidet die Frage, wem der Zustand gehört:
| Zustand | gehört | Weg | Beispiel in der Web-App |
|---|---|---|---|
| von der Elternkomponente vorgegeben | der Elternkomponente | Props | Angebot und ID für PlatzAnfrage |
| nur diese Komponente, mit wiederkehrender Logik | jeder Instanz einzeln | Composable | Ladezustand von Detailseite und Suche |
| nur diese Komponente, einmalig | der Komponente | ref in <script setup> |
Eingaben im Formular „Fahrt anbieten“ |
| viele Komponenten an unterschiedlichen Orten | der App | Store | gewählte Testperson, später die Anmeldung |
Deshalb landen die Ladezustände nicht im Store, wie es in stories.md geplant war: Kein zweiter Ort fragt, ob die Detailseite gerade lädt. PlatzAnfrage liest die Person weiterhin nicht selbst aus dem Store, sondern bekommt sie als Prop. So bleibt die Komponente ohne Pinia testbar, und nur die Seiten wissen, woher die Person kommt. Ein Store für jede Kleinigkeit würde den Zustand zwar zentral, aber nicht übersichtlich machen.
Refactoring in kleinen Schritten
Über alle Umbauten folgt das Paar demselben Ablauf: einen Smell auswählen, den kleinsten Schritt machen, der ihn verringert, alle Tests laufen lassen. Bei Grün wird committet, bei Rot wird der Schritt zurückgenommen oder im selben Schritt korrigiert, nie mit einem weiteren Umbau übermalt.
Im Repository steht der Umbau als Folge kleiner Commits, die sich einzeln lesen und zurücknehmen lassen:
| Nr. | Commit | Hut | Tests danach |
|---|---|---|---|
| 1 | Charakterisierungstest: Angebot trägt die ID der gewählten Testperson | Test ergänzt | 74 grün |
| 2 | useLaden() in AngebotSeite.vue |
Refactoring | 74 grün |
| 3 | useLaden() in FahrtenSuche.vue |
Refactoring | 74 grün |
| 4 | Test für useLaden(), verspätete Antwort verwerfen |
Fehlerbehebung | 77 grün (vorher 1 rot) |
| 5 | Pinia einbinden, useSitzungStore mit Test |
Refactoring | 80 grün |
| 6 | App.vue liest die Person aus dem Store, Tests mit frischem Pinia |
Refactoring | 80 grün |
| 7 | Detailseite und Angebotsseite aus dem Store, sitzung.ts gelöscht |
Refactoring | 80 grün |
Die Pipeline aus dem Artikel Vue Router und Continuous Integration läuft nach jedem Push; Continuous Integration in XP heißt auch, dass ein Umbau nicht tagelang auf einem eigenen Zweig liegt. Kleine Schritte machen das möglich: Jeder Commit ist für sich lauffähig.
Eine IDE wie VS Code nimmt einen Teil der Arbeit ab: Umbenennen (F2) ändert ein Symbol in allen Dateien, und „Datei verschieben“ passt die Importe an. Das ersetzt die Tests nicht. Ob ein watch noch dasselbe tut, nachdem er in ein Composable gewandert ist, prüft nur ein Test.
Iteration 4: nach dem Aufräumen
Für Thomas ändert sich nichts, und genau das zeigt das Team ihm kurz in der nächsten Kundenstunde: Die Kundentests aus den ersten drei Iterationen laufen alle grün, die App sieht aus wie vorher. Die Velocity bleibt unberührt, denn das Aufräumen war Teil der Aufgaben und keine eigene Karte. Zwei Dinge sind für den Rest der Iteration anders. Die Anmeldung muss nur noch den Store useSitzungStore füllen, statt drei Komponenten anzufassen. Und WEB-09 kann seine Liste der Anfragen mit useLaden() laden, ohne die Reihenfolge der Antworten neu zu bedenken.
In stories.md hält das Team den Stand fest und vermerkt, warum die Ladezustände nicht in den Store gewandert sind. Das ist auch eine Form von Refactoring: am Plan, bevor er Code wurde.
Die Ergebnisse im Repository
Den vollständigen Code-Stand findest Du im Ordner src/07-vue-refactoring-pinia des Repositorys dieser Reihe: das Composable in src/composables/useLaden.ts mit src/composables/__tests__/useLaden.spec.ts, der Store in src/stores/sitzung.ts mit src/stores/__tests__/sitzung.spec.ts, die umgebauten Komponenten App.vue, AngebotSeite.vue, AngebotNeuSeite.vue und FahrtenSuche.vue, src/main.ts mit Pinia sowie der Charakterisierungstest in src/__tests__/navigation.spec.ts. stories.md, CHANGELOG.md und README.md sind fortgeschrieben. npm test meldet 80 grüne Tests in 18 Dateien.
Nächster Schritt: Anmeldung mit Keycloak
Der Spike aus Iteration 4 steht als Nächstes an: Das Paar probiert zeitlich begrenzt aus, wie sich die App über OpenID Connect bei Keycloak anmeldet, und schätzt danach WEB-08. Im Artikel Anmeldung mit Keycloak ersetzt die Anmeldung dann die Auswahl der Testperson, an genau einer Stelle: im Store.
In den Übungsaufgaben zu diesem Artikel erkennst Du Smells im Code für Mitfahrgesuche, baust test-first einen Store für Gesuche, schließt eine Seite „Meine Gesuche“ mit useLaden() und zwei Stores an und findest heraus, warum die Tests eines anderen Paars nach dem Umzug in Pinia mal grün und mal rot sind.
Leitfragen
- Woran erkennst Du, ob eine Änderung ein Refactoring ist, und warum trennt das Team die Fehlerbehebung zur verspäteten Antwort vom Umbau?
Ein Refactoring ändert die innere Struktur, aber nicht das beobachtbare Verhalten; die Tests, die Verhalten prüfen, bleiben unverändert grün. Die verspätete Antwort zu verwerfen ändert das Verhalten, ist also eine Fehlerbehebung mit einem neuen Test, der zuerst rot ist. Würde das Paar beides in einem Schritt machen, wüsste es bei einem roten Test nicht, ob der Umbau falsch ist oder die gewollte Änderung greift (zwei Hüte). siehe Abschnitt „Zwei Hüte“
- Nenne die vier Regeln von Simple Design und eine Entscheidung in diesem Artikel, die sich mit Regel 4 begründen lässt.
Besteht alle Tests, drückt die Absicht aus, enthält keine Doppelungen, hat möglichst wenige Elemente. Mit Regel 4 begründet: Die Ladezustände kommen nicht in einen Store, weil kein zweiter Ort sie braucht; der Store useSitzungStore bekommt weder Getter pseudonym noch Action wechsleZu(), weil sie heute niemand nutzt. siehe Abschnitt „Simple Design“
- Welche Code Smells hat das Paar im Stand 06 gefunden, und welcher Test Smell hat auf einen davon hingewiesen?
Doppelter Code in den Ladezuständen von Detailseite und Suche, geteilter veränderlicher Zustand im modulweiten aktuellePersonId und spekulative Allgemeinheit im Plan, alle Ladezustände in einen Store zu legen. Der Test zu KT-05.2 musste den modulweiten Zustand am Anfang und Ende von Hand zurücksetzen, weil sich alle Tests einer Datei denselben ref teilen. siehe Abschnitt „Code Smells im Stand 06“
- Was ist ein Charakterisierungstest, und warum schreibt das Paar ihn vor dem ersten Umbau?
Ein Test, der festhält, was der Code heute tut, ohne es zu bewerten; er ist im alten Stand sofort grün. Kein Test hat geprüft, dass „Fahrt anbieten“ die gewählte Testperson als fahrerId mitschickt, und genau diese Verbindung ändert sich beim Umzug in den Store. Ohne den Test könnte der Umbau sie unbemerkt brechen. siehe Abschnitt „Das Testnetz prüfen und flicken“
- Worin unterscheidet sich ein Composable von einer Hilfsfunktion wie
ladeAngebot()und von einem Store?
Eine Hilfsfunktion bekommt Werte und liefert Werte, ohne Reaktivität. Ein Composable nutzt die Composition API (ref, watch …), wird im setup() einer Komponente aufgerufen, gibt reaktive Werte zurück und erzeugt bei jedem Aufruf eigenen Zustand. Ein Store hält dagegen einen Zustand, den alle Komponenten gemeinsam haben. siehe Abschnitt „Composables“ und Abschnitt „Composable, Store oder Props?“
- Aus welchen Teilen besteht ein Pinia-Store, und woran erkennt Pinia sie in der Setup-Schreibweise?
State (gespeicherter Zustand), Getter (abgeleitete Werte) und Actions (Funktionen, die den State ändern). In der Setup-Schreibweise ist ein zurückgegebener ref State, ein computed ein Getter und eine Funktion eine Action. Jeder State muss zurückgegeben werden. siehe Abschnitt „Pinia“
- Warum zeigt
const { personId } = useSitzungStore()nach einem Wechsel der Testperson noch die alte ID, und wie liest Du den Wert richtig?
Das Zerlegen liest den Wert einmal aus dem reaktiven Store und legt ihn in eine gewöhnliche Variable; Änderungen im Store erreichen sie nicht. Richtig ist storeToRefs(sitzung), das State und Getter als Refs liefert, oder der Zugriff über den Store selbst (sitzung.personId). Actions darf man direkt herausnehmen. siehe Abschnitt „Die Testperson zieht in den Store“
- Warum bekommt in den Tests jede montierte App ein eigenes
createPinia(), und was passiert ohne Pinia?
Ohne Pinia meldet useSitzungStore() den Fehler „no active Pinia“. Ein frisches Pinia je Test sorgt dafür, dass jeder Test bei der Vorgabe (Kaffeetasse) beginnt und kein Test vom Zustand eines vorherigen abhängt; das Zurücksetzen von Hand entfällt. siehe Abschnitt „Die Testperson zieht in den Store“
- Wie testet das Paar
useLaden()ohne Komponente, und wie legt der Test die Reihenfolge der Antworten fest?
Es ruft das Composable in effectScope().run() auf, das den Effekt-Bereich bereitstellt, den sonst setup() liefert. Die Ladefunktion gibt Promises zurück, die der Test selbst mit erfuellen() auflöst; so kommt die Antwort zur zweiten Anfrage vor der zur ersten an. siehe Abschnitt „Zwei Hüte“
Links und weitere Informationen
für den Einstieg: Formulare und Validierung in Vue mit dem Stand, der hier umgebaut wird
als Hintergrund: Refactoring: Code Smells erkennen und in sicheren Schritten umbauen und Einfach bleiben: KISS, YAGNI, DRY aus der Clean-Code-Reihe
als Hintergrund: Extreme Programming mit Werten und Praktiken
als Nachschlagewerk: Vue.js: Composables
als Nachschlagewerk: Pinia: Defining a Store und Pinia: Testing stores
als Nachschlagewerk: Martin Fowler: Refactoring mit dem Katalog der Refactorings und Bliki: BeckDesignRules
Hinweis zur Nachnutzung als Open Educational Resource (OER)
Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Refactoring mit Testnetz in Vue: Composables, Pinia und Simple Design” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/vue-refactoring-pinia veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/programmierung/vue. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 11.11.2026.
[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]
