Strukturelle direktiver i Angular er en spesiell type direktiver som endrer strukturen i DOM ved å legge til, fjerne eller manipulere elementer. De kjennetegnes lett ved at de starter med en stjerne (*) i templaten. Men det som skjer under panseret er mer komplekst enn en enkel konvertering til if-setninger eller løkker i JavaScript.

Angular bryter ned komponentens template i såkalte «views». Et view er et fragment av templaten som har statisk HTML-innhold, men kan inneholde dynamiske attributter eller tekster. For eksempel genererer en template med *ngIf og *ngFor tre separate views. Denne inndelingen gjør at Angular ikke manipulerer DOM direkte med if- og for-setninger, men styrer innsetting og fjerning av views dynamisk.

Selve stjerne-syntaksen er syntaktisk sukker for en attributtdirektiv som settes på et <ng-template>-element. Det betyr at direktivet ikke direkte manipulerer DOM-elementene, men heller styrer rendering av templates som Angular kan sette inn eller fjerne etter behov. Dette skjer via et ViewContainer, en slags beholder som kan holde på og styre child views. Angular plasserer et usynlig markørpunkt i DOM i form av en kommentar, og det er her views settes inn eller fjernes når betingelsen endrer seg.

Denne måten å organisere komponentens dynamiske innhold på åpner for mer effektiv oppdatering i fremtiden, siden Angular bare kan oppdatere de views som faktisk bruker signaler, i stedet for å rendre hele templaten på nytt.

Utviklere kan også lage egne strukturelle direktiver. Et eksempel er en egen *customNgIf, som tar en betingelse som input, injiserer ViewContainerRef og TemplateRef, og håndterer visningen i ngDoCheck-metoden. Dette viser at Angulars innebygde direktiver ikke er magiske, men bare velutviklede implementasjoner som benytter rammeverkets grunnprinsipper.

Likevel har denne tilnærmingen sine svakheter. For eksempel er det ofte tungvint å håndtere en «else»-tilstand i *ngIf, da det krever en ekstra input av typen TemplateRef. Bruken av «else»-blokk i templates er derfor ofte gjort på en omvei, noe som ikke alltid oppleves intuitivt.

I tillegg har strukturelle direktiver begrensninger når det gjelder typesjekking. Selv om Angular forsøker å hjelpe typesjekkeren med spesielle felt som ngTemplateGuard, er det flere tilfeller hvor typekontrollen ikke fungerer optimalt. For eksempel sjekkes ikke alltid «else»-delen av *ngIf korrekt, og NgSwitch som består av flere separate direktiver (NgSwitch, NgSwitchCase, NgSwitchDefault) har ingen kompileringsgaranti for riktig kontekstbruk.

Stjerne-syntaksen i seg selv kan også virke uklar og mindre intuitiv for nybegynnere, noe som ytterligere begrenser dens tilgjengelighet.

Derfor har Angular-teamet valgt å introdusere en ny kontrollflytsyntaks (@if, @for, @switch) i Angular v18. Denne nye syntaksen er mer eksplisitt og enklere å forstå, samtidig som den løser flere av de nevnte problemene, blant annet bedre typesjekking og enklere håndtering av kontrollflyt i templates.

Ved siden av strukturelle direktiver finnes det også mange ikke-strukturelle direktiver som spiller en viktig rolle i Angular-applikasjoner. Direktiver som ngStyle og ngClass gjør det mulig å dynamisk endre CSS-stiler og klasser på elementer, enten ved å binde til objekter eller ved å legge til og fjerne klasser basert på uttrykk. Dette gir utviklere kraftige verktøy for å manipulere utseendet til komponentene på en deklarativ måte.

Det er viktig å forstå at mens strukturelle direktiver styrer hvilke deler av DOM som vises, påvirker ikke-strukturelle direktiver hvordan eksisterende DOM-elementer oppfører seg eller ser ut. Sammen gir de et fleksibelt og uttrykksfullt rammeverk for å bygge dynamiske brukergrensesnitt.

Kunnskap om hvordan Angular håndterer rendering på et lavt nivå, og bevisstheten om strukturelle direktivers fordeler og begrensninger, gir et bedre grunnlag for å skrive mer effektive, vedlikeholdbare og type-sikre komponenter. Å være klar over kompleksiteten bak *-syntaksen og fordelene med den nye kontrollflytsyntaksen hjelper til å velge riktige verktøy for ulike situasjoner og kan redusere feil i større prosjekter.

Hvordan skrive enhetstester og ende-til-ende-tester i Angular

Testene i Angular kan deles inn i to hovedtyper: enhetstester og ende-til-ende-tester. Begge er nødvendige for å sikre at applikasjonen fungerer som den skal, men de har forskjellige styrker og svakheter som bør forstås for å gjøre de riktige valgene i utviklingsprosessen.

Enhetstester er raske og fokuserer på små deler av applikasjonen, som komponenter og tjenester. De kjøres i isolasjon, uten å interagere med eksterne systemer som nettverk eller databaser. Dette gir muligheten til å teste kode i en kontrollert og repetérbar kontekst. En typisk enhetstest kan være å sjekke at en komponent viser riktig data når den får spesifikke inngangsverdier. I Angular kan verktøy som TestBed brukes til å sette opp testene. For eksempel, i et testscenario for en komponent som viser informasjon om et dyr, kan vi bruke TestBed.overrideComponent() for å justere komponentens dekoratør, som importene, for å gjøre testen enklere ved å erstatte en komplisert komponent med en enklere versjon som bare simulerer nødvendig oppførsel.

Et eksempel på en enhetstest kan være å teste en komponent som viser et bilde av en ponni. Når en bruker aktiverer en knapp som setter ponniens tilstand til "løpende", skal bildet endres for å reflektere den nye tilstanden. Testen bør verifisere at det riktige bildet vises etter at tilstanden er endret, og eventuelt bruke metoder som detectChanges() for å trigge Angulars endringsdeteksjon.

En annen viktig ting er å bruke biblioteker som ngx-speculoos for å gjøre testene enklere og mer lesbare. Dette biblioteket tilbyr en rekke verktøy for å teste komponenter på en mer deklarativ og mindre repetitiv måte. Med det kan vi for eksempel bruke spesifikke metoder som selectLabel() for å velge et alternativ i en dropdown, i stedet for å manuelt utløse hendelser som dispatchEvent(). På denne måten kan testene skrives mer konsist og lett forståelig.

Når det kommer til ende-til-ende (e2e) tester, er de mer omfattende og tester applikasjonen i sin helhet. E2e-tester utføres i en ekte nettleser og simulerer brukerinteraksjoner, som klikk på knapper og utfylling av skjemaer. Disse testene gir oss en mer realistisk simulering av hvordan applikasjonen vil oppføre seg i produksjon, men de har også ulemper: de er langsommere enn enhetstester og kan være vanskelige å sette opp for kanttilfeller.

I Angular er det ikke en innebygd løsning for e2e-tester, men man kan bruke verktøy som Cypress eller Playwright, som er godt integrert med Angular CLI. Playwright, for eksempel, gir et svært robust API og tilbyr funksjoner som parallellkjøring på tvers av ulike nettlesere, noe som kan gjøre testene raskere og mer pålitelige. Den viktigste funksjonen som gjør Playwright til et populært valg er "time-travel debugging". Når en test feiler, kan man enkelt navigere til feilstedet og se applikasjonens tilstand på det tidspunktet.

Playwright tilbyr også en enkel måte å mocke HTTP-responser på, noe som kan være nyttig for å teste applikasjoner i isolasjon uten å være avhengig av backend-tjenester. Eksempelet med en påloggingsside illustrerer hvordan Playwright kan brukes til å teste en feilende pålogging ved å simulere en 401-feilrespons fra serveren og kontrollere at den riktige advarselen vises til brukeren.

Selv om e2e-tester gir mer dekning enn enhetstester, er det viktig å merke seg at de er tidkrevende å skrive og vedlikeholde. Derfor er det vanlig å kombinere begge testtypene: enhetstester for å sikre at individuelle deler fungerer som de skal, og e2e-tester for å validere at applikasjonen fungerer som en helhet.

Enda mer utfordrende er at selv om enhetstester er raske og enkle å kjøre lokalt, kan e2e-tester kreve en full nettleser og kan derfor være vanskeligere å integrere i kontinuerlig integrasjon (CI)-miljøer. Derfor bør man nøye vurdere hvilke deler av applikasjonen som skal testes med enhetstester, og hvilke som bør testes med e2e-tester, avhengig av deres kompleksitet og potensielle feil.

For en komplett teststrategi er det også viktig å inkludere en grundig testdekning av brukergrensesnittet (UI), da dette er et område der både enhetstester og e2e-tester kan bidra til å sikre at applikasjonen er brukervennlig og feilfri. UI-tester kan omfatte alt fra knappens synlighet og klikkbarhet til hvordan data presenteres for brukeren.

Hvordan Angular-routeren håndterer navigasjon og datahåndtering i en én-sides-applikasjon

I en typisk én-sides-applikasjon (SPA) med Angular skjer navigasjonen på en spesiell måte, som skiller seg betydelig fra tradisjonelle multi-sides-applikasjoner. Når en bruker klikker på en lenke, for eksempel for å vise et hesteveddeløp, skjer det en rekke steg før det faktiske innholdet vises. Routeren oppretter en instans av komponenten som viser løpet og sender en AJAX-forespørsel for å laste inn dataene som trengs. På dette tidspunktet blir malen til komponenten umiddelbart plassert på plasseringen til router-outlet, og URL-en i adressefeltet endres.

Brukeren ser den nye siden umiddelbart, men uten noe innhold – kun en blank side. Når AJAX-forespørselen er ferdigbehandlet og responsen er mottatt, lagres dataene for løpet i komponenten, og DOM-en oppdateres. Denne tilnærmingen har både fordeler og ulemper:

  • Navigasjonen føles raskere for brukeren, fordi siden vises umiddelbart.

  • Hvis innlasting av data tar lang tid, kan brukeren bli forvirret, da den blanke siden kan se ut som en feil.

  • Malen til komponenten må kodes med tanke på dette korte tidsvinduet, hvor dataene kan være null eller undefined.

  • Det kan vises umiddelbar tilbakemelding, som en melding eller en roterende animasjon, som indikerer at dataene lastes.

  • Hvis dataene ikke kan lastes, for eksempel ved tap av nettverkstilkobling, har navigasjonen allerede funnet sted og URL-en er endret, selv om innholdet ikke vises.

For å håndtere slike situasjoner mer elegant og unngå at brukeren opplever en ufullstendig side, kan man bruke en resolver. En resolver gjør at applikasjonen oppfører seg mer som en tradisjonell multi-sides-applikasjon. I stedet for at komponenten selv laster dataene, kan en resolver hente dem på vegne av komponenten. Som en guard, kan en resolver returnere data synkront eller asynkront, ved å bruke et Promise eller et Observable. Routeren vil kun navigere videre til den aktuelle ruten når dataene er lastet og klar til bruk.

Et eksempel på hvordan en resolver for et løp kan se ut, er som følger:

typescript
export const raceResolver: ResolveFn = (route: ActivatedRouteSnapshot): Observable<Race> => {
const id = route.paramMap.get('raceId')!; const raceService = inject(RaceService); return raceService.get(id); };

I denne koden hentes parameteren raceId fra ruten, og et Observable returneres fra en tjeneste som laster løpet basert på ID-en. Deretter kan resolveren knyttes til ruten på denne måten:

typescript
{
path: 'races/:raceId', component: Race, resolve: { race: raceResolver } }

Med denne løsningen blir løpskomponenten enklere å kode, ettersom den ikke trenger å håndtere datahåndtering. Dataene kan hentes direkte fra ruten ved å bruke følgende kode:

typescript
export class Race { protected readonly race = signal(inject(ActivatedRoute).snapshot.data['race']); }

En viktig detalj er at hvis man navigerer til samme rute med forskjellige parametere (f.eks. ved å bruke en "Neste løp"-lenke), vil både guards og resolvers bli kjørt på nytt, selv om komponenten fortsatt gjenbrukes. I slike tilfeller bør komponenten abonnere på dataene som hentes, eller bruke et Observable og async pipe i maler:

typescript
export class Race {
private readonly route = inject(ActivatedRoute);
protected readonly raceModel = toSignal(this.route.data.pipe(map(data => data['race']))); }

Fordelene med å bruke resolvers er flere:

  • De gjør navigasjonen mer tradisjonell, hvor brukeren får den forventede opplevelsen av at siden lastes ferdig før den vises.

  • Resolvers kan deles mellom flere ruter, og dermed gjenbrukes på tvers av applikasjonen.

  • De gjør komponentkoden enklere, da dataene hentes på forhånd, og komponenten kun trenger å bruke dem, uten å bekymre seg for midlertidige null-verdier eller kompleks datahåndtering.

  • Hvis navigasjonen feiler (for eksempel på grunn av et nettverksbrudd), kan brukeren enkelt prøve på nytt, uten at det er behov for å gå tilbake til forrige side.

En potensiell ulempe ved å bruke resolvers er at applikasjonen kan føles tregere hvis datalastingen er langsom, ettersom brukeren kanskje må vente uten visuell tilbakemelding før innholdet er lastet. I slike tilfeller kan det være mer brukervennlig å laste dataene direkte i komponenten og vise en lastemelding eller animasjon mens dataene hentes. Et annet alternativ er å bruke router-hendelser for å vise en lastemelding mens dataene lastes.

Routeren i Angular sender flere hendelser under navigasjonen, og man kan abonnere på disse hendelsene for å tilpasse applikasjonens oppførsel. Ved å bruke routerens hendelser kan vi f.eks. vise en spinner under navigasjonen:

  • NavigationStart: Utsendes når en navigasjon startes.

  • NavigationEnd: Utsendes når navigasjonen er fullført med suksess.

  • NavigationError: Utsendes når navigasjonen feiler, for eksempel ved feil i resolveren.

  • NavigationCancel: Utsendes når navigasjonen blir avbrutt.

Ved å lytte på disse hendelsene, kan utviklere få detaljert kontroll over brukeropplevelsen og forbedre visuelle tilbakemeldinger under navigasjon.

En annen viktig funksjon er håndtering av URL-parametere, som kan være både matrise-parametere og spørringsparametere. Matrise-parametere er spesifikke for den aktuelle ruten og brukes sjeldnere enn spørringsparametere. Eksempelvis, i URL-en /races/42;foo=bar;baz=wiz vil parametrene foo og baz være tilgjengelige på den delen av ruten som har races/42, men ikke for den videre delen, som f.eks. /ponies. Å forstå denne forskjellen kan være viktig for riktig håndtering av data i applikasjoner med komplekse ruter.

Endtext