From 4e018777198c0ea876f3395d067bc25da555d10c Mon Sep 17 00:00:00 2001 From: Tord Tefre Stavnsborg Date: Tue, 15 Sep 2026 13:21:13 +0200 Subject: [PATCH 1/8] test(src): legg til CountryCard-tester og delt mock-hjelper for landdata Co-Authored-By: Birk --- src/components/CountryCard.test.tsx | 69 +++++++++++++++++++++++++++++ src/test/mockCountry.ts | 17 +++++++ 2 files changed, 86 insertions(+) create mode 100644 src/components/CountryCard.test.tsx create mode 100644 src/test/mockCountry.ts diff --git a/src/components/CountryCard.test.tsx b/src/components/CountryCard.test.tsx new file mode 100644 index 0000000..ade5e7d --- /dev/null +++ b/src/components/CountryCard.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { createMockCountry } from '../test/mockCountry'; +import CountryCard from './CountryCard'; + +describe('CountryCard', () => { + it('viser alle fakta når landet har fullstendige data', () => { + const country = createMockCountry({ + languages: ['Norwegian', 'Sami'], + }); + const { container } = render(); + + expect(screen.getByRole('article', { name: 'Norway' })).toBeInTheDocument(); + expect( + screen.getByRole('img', { name: 'Flagget til Norway' }), + ).toHaveAttribute('src', country.flagUrl); + + const facts = container.querySelectorAll('dd'); + expect(facts[0]).toHaveTextContent('Oslo'); + expect(facts[1]).toHaveTextContent('Europe'); + expect(facts[2]).toHaveTextContent('5 400 000'); + expect(facts[3]).toHaveTextContent('385 207 km²'); + expect(facts[4]).toHaveTextContent('Norwegian og Sami'); + }); + + it('viser «Ikke oppgitt» og reservetekst for flagg når data mangler', () => { + const country = createMockCountry({ + flagUrl: undefined, + capital: undefined, + region: undefined, + population: undefined, + area: undefined, + languages: [], + }); + const { container } = render(); + + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.getByText('Flagg ikke tilgjengelig')).toBeInTheDocument(); + + const facts = container.querySelectorAll('dd'); + facts.forEach((fact) => { + expect(fact).toHaveTextContent('Ikke oppgitt'); + }); + }); + + it('viser reservetekst når flaggbildet ikke kan lastes', () => { + const country = createMockCountry(); + render(); + + const flag = screen.getByRole('img', { name: 'Flagget til Norway' }); + fireEvent.error(flag); + + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.getByText('Flagg ikke tilgjengelig')).toBeInTheDocument(); + }); + + it('rendrer children ved siden av landnavnet', () => { + const country = createMockCountry(); + render( + + + , + ); + + expect( + screen.getByRole('button', { name: 'Favoritt' }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/test/mockCountry.ts b/src/test/mockCountry.ts new file mode 100644 index 0000000..6e0b076 --- /dev/null +++ b/src/test/mockCountry.ts @@ -0,0 +1,17 @@ +import type { Country } from '../types/country'; + +// Et fullstendig land med faste verdier, til bruk som utgangspunkt i tester. +// Overrides lar en test bare endre feltene den faktisk bryr seg om. +export function createMockCountry(overrides: Partial = {}): Country { + return { + code: 'NOR', + name: 'Norway', + flagUrl: 'https://example.test/flags/nor.svg', + capital: 'Oslo', + region: 'Europe', + population: 5_400_000, + area: 385_207, + languages: ['Norwegian'], + ...overrides, + }; +} From 34bca27f72961b1c1e432aeb0b09c979d6892f07 Mon Sep 17 00:00:00 2001 From: Tord Tefre Stavnsborg Date: Tue, 15 Sep 2026 13:22:36 +0200 Subject: [PATCH 2/8] test(src): legg til brukerinteraksjonstester for landmeny og navigasjon Co-Authored-By: Birk --- src/components/CountryMenu.test.tsx | 85 +++++++++++++++++++++++ src/components/CountryNavigation.test.tsx | 79 +++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 src/components/CountryMenu.test.tsx create mode 100644 src/components/CountryNavigation.test.tsx diff --git a/src/components/CountryMenu.test.tsx b/src/components/CountryMenu.test.tsx new file mode 100644 index 0000000..a37e9b1 --- /dev/null +++ b/src/components/CountryMenu.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { createMockCountry } from '../test/mockCountry'; +import CountryMenu from './CountryMenu'; + +const countries = [ + createMockCountry({ code: 'NOR', name: 'Norway' }), + createMockCountry({ code: 'SWE', name: 'Sweden' }), +]; + +describe('CountryMenu', () => { + it('velger et land ved klikk og markerer det med aria-current', async () => { + const onSelect = vi.fn(); + render( + false} + />, + ); + + const selected = screen.getByRole('button', { name: 'Norway' }); + const other = screen.getByRole('button', { name: 'Sweden' }); + expect(selected).toHaveAttribute('aria-current', 'true'); + expect(other).not.toHaveAttribute('aria-current'); + + await userEvent.click(other); + + expect(onSelect).toHaveBeenCalledWith('SWE'); + }); + + it('markerer favorittland med stjerne og skjult tekst', () => { + render( + code === 'SWE'} + />, + ); + + const favoriteItem = screen.getByRole('button', { name: /Sweden/ }); + expect(favoriteItem).toHaveTextContent('(favoritt)'); + expect( + screen.getByRole('button', { name: 'Norway' }), + ).not.toHaveTextContent('(favoritt)'); + }); + + it('viser melding og ingen landliste ved tomt utvalg', () => { + render( + false} + />, + ); + + expect(screen.getByText('Ingen land i utvalget.')).toBeInTheDocument(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); + + it('lukker listen og flytter fokus til toggle-knappen etter valg', async () => { + const onSelect = vi.fn(); + render( + false} + />, + ); + + const toggle = screen.getByRole('button', { name: /Landliste/ }); + await userEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + + await userEvent.click(screen.getByRole('button', { name: 'Sweden' })); + + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(toggle).toHaveFocus(); + }); +}); diff --git a/src/components/CountryNavigation.test.tsx b/src/components/CountryNavigation.test.tsx new file mode 100644 index 0000000..5d4606e --- /dev/null +++ b/src/components/CountryNavigation.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { createMockCountry } from '../test/mockCountry'; +import CountryNavigation from './CountryNavigation'; + +const countries = [ + createMockCountry({ code: 'NOR', name: 'Norway' }), + createMockCountry({ code: 'SWE', name: 'Sweden' }), + createMockCountry({ code: 'DNK', name: 'Denmark' }), +]; + +describe('CountryNavigation', () => { + it('viser posisjon og går til neste land ved klikk', async () => { + const onSelect = vi.fn(); + render( + , + ); + + expect(screen.getByText('Land 2 av 3')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Neste' })); + + expect(onSelect).toHaveBeenCalledWith('DNK'); + }); + + it('går til forrige land ved klikk', async () => { + const onSelect = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Forrige' })); + + expect(onSelect).toHaveBeenCalledWith('NOR'); + }); + + it('deaktiverer «Forrige» ved første og «Neste» ved siste land', () => { + const { rerender } = render( + , + ); + expect(screen.getByRole('button', { name: 'Forrige' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Neste' })).toBeEnabled(); + + rerender( + , + ); + expect(screen.getByRole('button', { name: 'Forrige' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Neste' })).toBeDisabled(); + }); + + it('rendrer ingenting når valgt kode ikke finnes i utvalget', () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); From f63ba2d8e0895e9a58de8dff0494f1eadaa24393 Mon Sep 17 00:00:00 2001 From: Tord Tefre Stavnsborg Date: Tue, 15 Sep 2026 13:25:18 +0200 Subject: [PATCH 3/8] test(src): legg til favoritt-tester og snapshot av FavoriteButton Co-Authored-By: Birk --- src/components/FavoriteButton.test.tsx | 23 ++++++ .../FavoriteButton.test.tsx.snap | 39 ++++++++++ src/hooks/useFavorites.test.ts | 42 +++++++++++ src/utils/favoritesStorage.test.ts | 74 +++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 src/components/__snapshots__/FavoriteButton.test.tsx.snap create mode 100644 src/hooks/useFavorites.test.ts create mode 100644 src/utils/favoritesStorage.test.ts diff --git a/src/components/FavoriteButton.test.tsx b/src/components/FavoriteButton.test.tsx index e6b9e50..0b61bae 100644 --- a/src/components/FavoriteButton.test.tsx +++ b/src/components/FavoriteButton.test.tsx @@ -35,4 +35,27 @@ describe('FavoriteButton', () => { screen.getByRole('button', { name: 'Favoritt: Norge' }), ).toHaveAttribute('aria-pressed', 'true'); }); + + // FavoriteButton er liten og rent presentasjonsmessig, uten egen state. + // Markeringen fanger derfor uventede endringer i strukturen, ikke i logikken. + it('matcher snapshot i av- og på-tilstand', () => { + const off = render( + , + ); + expect(off.container).toMatchSnapshot('av'); + off.unmount(); + + const on = render( + , + ); + expect(on.container).toMatchSnapshot('på'); + }); }); diff --git a/src/components/__snapshots__/FavoriteButton.test.tsx.snap b/src/components/__snapshots__/FavoriteButton.test.tsx.snap new file mode 100644 index 0000000..3a557d3 --- /dev/null +++ b/src/components/__snapshots__/FavoriteButton.test.tsx.snap @@ -0,0 +1,39 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`FavoriteButton > matcher snapshot i av- og på-tilstand > av 1`] = ` +
+ +
+`; + +exports[`FavoriteButton > matcher snapshot i av- og på-tilstand > på 1`] = ` +
+ +
+`; diff --git a/src/hooks/useFavorites.test.ts b/src/hooks/useFavorites.test.ts new file mode 100644 index 0000000..38c6865 --- /dev/null +++ b/src/hooks/useFavorites.test.ts @@ -0,0 +1,42 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { FAVORITES_STORAGE_KEY } from '../utils/favoritesStorage'; +import { useFavorites } from './useFavorites'; + +describe('useFavorites', () => { + it('starter uten favoritter når lagring er tom', () => { + const { result } = renderHook(() => useFavorites()); + + expect(result.current.isFavorite('NOR')).toBe(false); + }); + + it('legger til og fjerner favoritt, og lagrer endringen i localStorage', () => { + const { result } = renderHook(() => useFavorites()); + + act(() => { + result.current.toggleFavorite('NOR'); + }); + expect(result.current.isFavorite('NOR')).toBe(true); + expect(localStorage.getItem(FAVORITES_STORAGE_KEY)).toBe( + JSON.stringify(['NOR']), + ); + + act(() => { + result.current.toggleFavorite('NOR'); + }); + expect(result.current.isFavorite('NOR')).toBe(false); + expect(localStorage.getItem(FAVORITES_STORAGE_KEY)).toBe( + JSON.stringify([]), + ); + }); + + it('gjenoppretter favoritter fra localStorage ved ny montering', () => { + localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(['BRA', 'BGD'])); + + const { result } = renderHook(() => useFavorites()); + + expect(result.current.isFavorite('BRA')).toBe(true); + expect(result.current.isFavorite('BGD')).toBe(true); + expect(result.current.isFavorite('NOR')).toBe(false); + }); +}); diff --git a/src/utils/favoritesStorage.test.ts b/src/utils/favoritesStorage.test.ts new file mode 100644 index 0000000..679ef9a --- /dev/null +++ b/src/utils/favoritesStorage.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + FAVORITES_STORAGE_KEY, + loadFavorites, + saveFavorites, +} from './favoritesStorage'; + +describe('loadFavorites', () => { + it('returnerer tom liste når ingenting er lagret', () => { + expect(loadFavorites()).toEqual([]); + }); + + it('leser en gyldig, lagret liste', () => { + localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(['NOR', 'SWE'])); + + expect(loadFavorites()).toEqual(['NOR', 'SWE']); + }); + + it('fjerner duplikater', () => { + localStorage.setItem( + FAVORITES_STORAGE_KEY, + JSON.stringify(['NOR', 'SWE', 'NOR']), + ); + + expect(loadFavorites()).toEqual(['NOR', 'SWE']); + }); + + it('hopper over verdier som ikke er tekst eller er tomme', () => { + localStorage.setItem( + FAVORITES_STORAGE_KEY, + JSON.stringify(['NOR', 7, null, '', { a: 1 }, 'SWE']), + ); + + expect(loadFavorites()).toEqual(['NOR', 'SWE']); + }); + + it('returnerer tom liste ved ugyldig JSON', () => { + localStorage.setItem(FAVORITES_STORAGE_KEY, 'ikke gyldig json'); + + expect(loadFavorites()).toEqual([]); + }); + + it('returnerer tom liste når lagret verdi ikke er en liste', () => { + localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify({ a: 1 })); + + expect(loadFavorites()).toEqual([]); + }); + + it('returnerer tom liste når lesing kaster feil', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blokkert'); + }); + + expect(loadFavorites()).toEqual([]); + }); +}); + +describe('saveFavorites', () => { + it('lagrer landkodene som en JSON-liste', () => { + saveFavorites(['NOR', 'SWE']); + + expect(localStorage.getItem(FAVORITES_STORAGE_KEY)).toBe( + JSON.stringify(['NOR', 'SWE']), + ); + }); + + it('kaster ikke feil når lagring er blokkert', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blokkert'); + }); + + expect(() => saveFavorites(['NOR'])).not.toThrow(); + }); +}); From db8892fcbab267addf54cb468e5cf89c45bbd22c Mon Sep 17 00:00:00 2001 From: Tord Tefre Stavnsborg Date: Tue, 15 Sep 2026 13:28:11 +0200 Subject: [PATCH 4/8] test(src): legg til tester for sortering og gjenoppretting av sorteringsvalg Co-Authored-By: Birk --- src/utils/sortCountries.test.ts | 58 ++++++++++++++++++++++++++++++ src/utils/sortOrderStorage.test.ts | 49 +++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/utils/sortCountries.test.ts create mode 100644 src/utils/sortOrderStorage.test.ts diff --git a/src/utils/sortCountries.test.ts b/src/utils/sortCountries.test.ts new file mode 100644 index 0000000..64d6217 --- /dev/null +++ b/src/utils/sortCountries.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { createMockCountry } from '../test/mockCountry'; +import { sortCountries } from './sortCountries'; + +// Navn som dekker norsk sortering: U kommer før Æ, Ø og Å. +const countries = [ + createMockCountry({ code: 'ALB', name: 'Albania', population: 200 }), + createMockCountry({ code: 'BRA', name: 'Brasil', population: 500 }), + createMockCountry({ code: 'UKJ', name: 'Ukjentland', population: undefined }), + createMockCountry({ code: 'OST', name: 'Østland', population: 300 }), + createMockCountry({ code: 'ALE', name: 'Ålesundia', population: 400 }), +]; + +describe('sortCountries', () => { + it('sorterer alfabetisk med norsk sortering', () => { + const sorted = sortCountries(countries, 'name'); + + expect(sorted.map((country) => country.name)).toEqual([ + 'Albania', + 'Brasil', + 'Ukjentland', + 'Østland', + 'Ålesundia', + ]); + }); + + it('sorterer etter befolkning, synkende, med manglende verdi sist', () => { + const sorted = sortCountries(countries, 'population'); + + expect(sorted.map((country) => country.name)).toEqual([ + 'Brasil', + 'Ålesundia', + 'Østland', + 'Albania', + 'Ukjentland', + ]); + }); + + it('bruker navn som tiebreak ved lik befolkning', () => { + const tied = [ + createMockCountry({ code: 'BRV', name: 'Bravo', population: 100 }), + createMockCountry({ code: 'ALP', name: 'Alpha', population: 100 }), + ]; + + const sorted = sortCountries(tied, 'population'); + + expect(sorted.map((country) => country.name)).toEqual(['Alpha', 'Bravo']); + }); + + it('endrer ikke originallisten', () => { + const frozen = Object.freeze([...countries]); + + expect(() => sortCountries(frozen, 'population')).not.toThrow(); + expect(frozen.map((country) => country.name)).toEqual( + countries.map((country) => country.name), + ); + }); +}); diff --git a/src/utils/sortOrderStorage.test.ts b/src/utils/sortOrderStorage.test.ts new file mode 100644 index 0000000..af56507 --- /dev/null +++ b/src/utils/sortOrderStorage.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DEFAULT_SORT_ORDER } from './sortCountries'; +import { + SORT_ORDER_STORAGE_KEY, + loadSortOrder, + saveSortOrder, +} from './sortOrderStorage'; + +describe('loadSortOrder', () => { + it('returnerer standardvalget når ingenting er lagret', () => { + expect(loadSortOrder()).toBe(DEFAULT_SORT_ORDER); + }); + + it('leser en gyldig, lagret sortering', () => { + sessionStorage.setItem(SORT_ORDER_STORAGE_KEY, 'population'); + + expect(loadSortOrder()).toBe('population'); + }); + + it('returnerer standardvalget ved en ugyldig lagret verdi', () => { + sessionStorage.setItem(SORT_ORDER_STORAGE_KEY, 'hacket'); + + expect(loadSortOrder()).toBe(DEFAULT_SORT_ORDER); + }); + + it('returnerer standardvalget når lesing kaster feil', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blokkert'); + }); + + expect(loadSortOrder()).toBe(DEFAULT_SORT_ORDER); + }); +}); + +describe('saveSortOrder', () => { + it('lagrer sorteringen', () => { + saveSortOrder('population'); + + expect(sessionStorage.getItem(SORT_ORDER_STORAGE_KEY)).toBe('population'); + }); + + it('kaster ikke feil når lagring er blokkert', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blokkert'); + }); + + expect(() => saveSortOrder('name')).not.toThrow(); + }); +}); From c7dde32554383e076b4b90c56ea991d66196f42d Mon Sep 17 00:00:00 2001 From: Tord Tefre Stavnsborg Date: Tue, 15 Sep 2026 13:29:38 +0200 Subject: [PATCH 5/8] =?UTF-8?q?test(src):=20legg=20til=20fetchCountries-te?= =?UTF-8?q?ster=20for=20feilh=C3=A5ndtering=20og=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Birk --- src/api/countries.test.ts | 151 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/api/countries.test.ts diff --git a/src/api/countries.test.ts b/src/api/countries.test.ts new file mode 100644 index 0000000..bbd34a6 --- /dev/null +++ b/src/api/countries.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fetchCountries } from './countries'; + +function mockFetchOnce(response: { + ok: boolean; + status?: number; + json: () => Promise; +}) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ status: 200, ...response }), + ); +} + +describe('fetchCountries', () => { + it('mapper og validerer felter fra det rå API-svaret', async () => { + mockFetchOnce({ + ok: true, + json: async () => [ + { + alpha3Code: 'NOR', + name: 'Norway', + flags: { svg: 'nor.svg', png: 'nor.png' }, + capital: 'Oslo', + region: 'Europe', + population: 5_400_000, + area: 385_207, + languages: [{ name: 'Norwegian' }], + }, + // Bare kode og navn er nødvendig, resten blir undefined/tom liste. + { alpha3Code: 'SWE', name: 'Sweden' }, + // Mangler kode: hoppes over. + { name: 'Uten kode' }, + // Feil type på population/area, og languages er ikke en array. + { + alpha3Code: 'DNK', + name: 'Denmark', + population: 'mange', + area: 'stort', + languages: 'ikke en liste', + }, + // Bare png-flagg tilgjengelig. + { alpha3Code: 'ISL', name: 'Iceland', flags: { png: 'isl.png' } }, + ], + }); + + const countries = await fetchCountries(); + + expect(countries).toEqual([ + { + code: 'NOR', + name: 'Norway', + flagUrl: 'nor.svg', + capital: 'Oslo', + region: 'Europe', + population: 5_400_000, + area: 385_207, + languages: ['Norwegian'], + }, + { + code: 'SWE', + name: 'Sweden', + flagUrl: undefined, + capital: undefined, + region: undefined, + population: undefined, + area: undefined, + languages: [], + }, + { + code: 'DNK', + name: 'Denmark', + flagUrl: undefined, + capital: undefined, + region: undefined, + population: undefined, + area: undefined, + languages: [], + }, + { + code: 'ISL', + name: 'Iceland', + flagUrl: 'isl.png', + capital: undefined, + region: undefined, + population: undefined, + area: undefined, + languages: [], + }, + ]); + }); + + it('gir tom liste for et tomt svar', async () => { + mockFetchOnce({ ok: true, json: async () => [] }); + + expect(await fetchCountries()).toEqual([]); + }); + + it('gir feil ved HTTP-feil', async () => { + mockFetchOnce({ ok: false, status: 500, json: async () => null }); + + await expect(fetchCountries()).rejects.toThrow( + 'API-et svarte med en feil (HTTP 500).', + ); + }); + + it('gir feil når svaret ikke er en array', async () => { + mockFetchOnce({ ok: true, json: async () => ({ not: 'an array' }) }); + + await expect(fetchCountries()).rejects.toThrow( + 'Svaret fra API-et hadde et uventet format.', + ); + }); + + it('gir feil når svaret ikke er gyldig JSON', async () => { + mockFetchOnce({ + ok: true, + json: async () => { + throw new Error('unexpected token'); + }, + }); + + await expect(fetchCountries()).rejects.toThrow( + 'Svaret fra API-et var ikke gyldig JSON.', + ); + }); + + it('gir feil ved nettverksfeil', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockRejectedValue(new TypeError('Failed to fetch')), + ); + + await expect(fetchCountries()).rejects.toThrow( + 'Fikk ikke kontakt med API-et. Sjekk nettforbindelsen.', + ); + }); + + it('sender avbrutt forespørsel videre uendret', async () => { + const abortError = new DOMException( + 'The operation was aborted.', + 'AbortError', + ); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError)); + + const controller = new AbortController(); + controller.abort(); + + await expect(fetchCountries(controller.signal)).rejects.toBe(abortError); + }); +}); From 9be30e0a862a70121809a6e01bdc45a0bd258548 Mon Sep 17 00:00:00 2001 From: Tord Tefre Stavnsborg Date: Tue, 15 Sep 2026 13:33:00 +0200 Subject: [PATCH 6/8] test(src): legg til App-integrasjonstester og fjern testadvarsel om jsdom Co-Authored-By: Birk --- src/App.test.tsx | 134 +++++++++++++++++++++++++++++++++++++++++++++++ vite.config.ts | 3 ++ 2 files changed, 137 insertions(+) create mode 100644 src/App.test.tsx diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..2ae0041 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,134 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { createTestQueryClient } from './test/queryClient'; +import App from './App'; + +const RAW_COUNTRIES = [ + { alpha3Code: 'NOR', name: 'Norway', population: 5_400_000 }, + { alpha3Code: 'SWE', name: 'Sweden', population: 10_000_000 }, +]; + +function stubFetchResolved(json: unknown, ok = true, status = 200) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok, status, json: async () => json }), + ); +} + +function renderApp() { + const queryClient = createTestQueryClient(); + return render( + + + , + ); +} + +describe('App', () => { + it('viser lastetilstand mens data hentes', () => { + // Løser seg aldri i denne testen, så komponenten blir stående i pending. + vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {}))); + + renderApp(); + + expect(screen.getByRole('status')).toHaveTextContent('Laster land'); + }); + + it('viser feilmelding ved HTTP-feil og henter på nytt ved «Prøv igjen»', async () => { + // useCountries har retry: 1, så de to første kallene (første forsøk og + // det automatiske nye forsøket) må feile før feilsiden vises. + let callCount = 0; + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async () => { + callCount += 1; + if (callCount <= 2) { + return { ok: false, status: 500, json: async () => null }; + } + return { ok: true, status: 200, json: async () => RAW_COUNTRIES }; + }), + ); + + renderApp(); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('HTTP 500'); + + await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' })); + + expect(await screen.findByText('Land 1 av 2')).toBeInTheDocument(); + }); + + it('viser melding når API-et ikke returnerer land', async () => { + stubFetchResolved([]); + + renderApp(); + + expect(await screen.findByText('Fant ingen land.')).toBeInTheDocument(); + }); + + it('viser melding om tomt favorittutvalg og lar brukeren vise alle land igjen', async () => { + stubFetchResolved(RAW_COUNTRIES); + + renderApp(); + await screen.findByText('Land 1 av 2'); + + await userEvent.click( + screen.getByRole('checkbox', { name: 'Vis bare favoritter' }), + ); + + expect( + screen.getByText('Du har ingen favorittland å vise.'), + ).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('button', { name: 'Vis alle land' }), + ); + + expect(await screen.findByText('Land 1 av 2')).toBeInTheDocument(); + }); + + it('viser bare favoritter når filteret er på', async () => { + stubFetchResolved(RAW_COUNTRIES); + + renderApp(); + // Norway vises først, siden standardsorteringen er alfabetisk. + await screen.findByText('Land 1 av 2'); + + await userEvent.click( + screen.getByRole('button', { name: 'Favoritt: Norway' }), + ); + await userEvent.click( + screen.getByRole('checkbox', { name: 'Vis bare favoritter' }), + ); + + expect(screen.getByText('Land 1 av 1')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Norway' })).toBeInTheDocument(); + }); + + it('viser tomt favorittutvalg når siste favoritt fjernes mens filteret er på', async () => { + stubFetchResolved(RAW_COUNTRIES); + + renderApp(); + await screen.findByText('Land 1 av 2'); + + await userEvent.click( + screen.getByRole('button', { name: 'Favoritt: Norway' }), + ); + await userEvent.click( + screen.getByRole('checkbox', { name: 'Vis bare favoritter' }), + ); + expect(screen.getByText('Land 1 av 1')).toBeInTheDocument(); + + // Fjerner den eneste favoritten mens filteret fortsatt er på. + await userEvent.click( + screen.getByRole('button', { name: 'Favoritt: Norway' }), + ); + + expect( + screen.getByText('Du har ingen favorittland å vise.'), + ).toBeInTheDocument(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 051fdd7..f653c6c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,5 +9,8 @@ export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], + // Gjenbruker jsdom-miljøet per worker i stedet for å lage det på nytt + // for hver testfil, uten å gi opp isolasjon mellom filene. + pool: 'vmThreads', }, }); From b568c77dcbc8e718408d3fdd69a1aad887657be5 Mon Sep 17 00:00:00 2001 From: Tord Tefre Stavnsborg Date: Tue, 15 Sep 2026 13:40:17 +0200 Subject: [PATCH 7/8] docs(docs): dokumenter automatisk testdekning i README Co-Authored-By: Birk --- README.md | 120 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 79 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index cf8715c..1994873 100644 --- a/README.md +++ b/README.md @@ -440,10 +440,8 @@ Implementert: - Filter som viser bare favorittland, lagret i sessionStorage. - Responsiv layout for desktop, nettbrett og mobil i stående og liggende format. - -Gjenstår i senere issues: - -- Automatiske tester, se [Testing](#testing). +- Automatiske komponent-, hook- og integrasjonstester med Vitest, se + [Testing](#testing). ## Arbeidsflyt @@ -453,7 +451,8 @@ pull requests med kodegjennomgang fra et annet gruppemedlem. ## Testing -Automatiske komponent- og hook-tester kjøres med Vitest. +Automatiske komponent-, hook- og integrasjonstester kjøres med Vitest, se +[Automatisk testdekning](#automatisk-testdekning) for hva som er dekket. Kommandoen `npm run lint` er statisk kodekontroll, ikke en funksjonstest. ### Testoppsett @@ -674,42 +673,81 @@ Testet i nettleserens enhetsemulering: - Lastetilstand, feilmelding og tomt favorittutvalg passer inn i layouten. - Ikke testet på ekte mobil eller i Safari ennå. -Automatiske tester som gjenstår, planlagt i en egen issue: - -- `fetchCountries`: vellykket henting, HTTP-feil, nettverksfeil, ugyldig - JSON, uventet format, tom respons, manglende felter og avbrutt - forespørsel, med mocket `fetch`. -- `App`: lastetilstand, feilmelding med riktig tekst, «Prøv igjen», tomt - resultat, og at eksisterende data vises når en ny henting feiler. -- `CountryCard`: fullstendige data, manglende felter og flagg som ikke - kan lastes. -- `CountryNavigation`: forrige/neste gir riktig land, knappene er - deaktivert ved første og siste land, posisjonen «Land X av Y» og liste - med ett land. -- `CountryMenu`: klikk på et land velger det, valgt land har - `aria-current`, favoritter er markert, og tomt utvalg gir melding. - «Landliste»-knappen bytter `aria-expanded`, og valg av land lukker - listen og flytter fokus til knappen. -- `App`: kort og navigasjon viser samme land, og ugyldig eller manglende - valgt kode gir første land. -- `sortCountries`: navn med norske bokstaver, synkende befolkning, - manglende befolkning sist, likt antall etter navn og uendret original. -- `loadSortOrder`/`saveSortOrder`: gyldig og ugyldig lagret verdi, og - lagring som kaster feil. -- `App` med sortering: valgt land beholdes ved bytte av sortering, - navigasjonen følger sortert liste, og valget gjenopprettes ved ny - montering. -- `loadFavorites`/`saveFavorites`: gyldig liste, ugyldig JSON, feil - format, duplikater og lagring som kaster feil. -- `useFavorites`/`FavoriteButton`: legge til og fjerne favoritt, - `aria-pressed`, riktig status ved blaing og gjenoppretting ved ny - montering. -- `loadShowOnlyFavorites`/`saveShowOnlyFavorites`: `"true"`, manglende - eller ugyldig verdi, og lagring som kaster feil. -- `App` med favorittfilter: utvalget inneholder bare favoritter i valgt - sortering, valgt land beholdes eller byttes til første land, tomt - utvalg viser melding og «Vis alle land», og fjerning av siste favoritt - krasjer ikke. +### Automatisk testdekning + +Se [Testoppsett](#testoppsett) for hvordan testene kjøres. Testene ligger +ved siden av filen de tester (for eksempel `CountryCard.test.tsx` ved +siden av `CountryCard.tsx`), og bruker mockede API-svar og en isolert +`QueryClient` der det er aktuelt, se [Testoppsett](#testoppsett). + +- **`src/api/countries.test.ts`:** Et rått svar med fem land gir fire + gyldige `Country`-objekter. Et land uten `alpha3Code` hoppes over, et + land med bare `png`-flagg får `flagUrl` satt til `png`-URL-en, og + tekstverdier for `population`/`area` og en `languages`-verdi som ikke + er en array, blir til `undefined`/tom liste. Tomt svar (`[]`) gir tom + liste. HTTP 500, et objekt i stedet for en array, og en `fetch` som + kaster ved `json()`, gir hver sin feilmelding fra + [Feilhåndtering](#feilhåndtering). En avbrutt forespørsel (signal + allerede `aborted`) sender den opprinnelige feilen videre uendret. +- **`src/App.test.tsx`:** En `fetch` som aldri løser seg, viser + «Laster land …». To mislykkede kall (første forsøk og det automatiske + nye forsøket, se `retry: 1` i [Hentestrategien](#hentestrategi-med-tanstack-query)) + gir feilmeldingen med «HTTP 500», og et tredje kall via «Prøv igjen» + lykkes og viser «Land 1 av 2». Et tomt svar gir «Fant ingen land.». + Med Norway og Sweden i utvalget: å markere Norway som favoritt og slå + på favorittfilteret gir «Land 1 av 1» og bare Norway i visningen. Uten + favoritter gir filteret meldingen «Du har ingen favorittland å vise.», + og «Vis alle land» gjenoppretter begge landene. Fjernes Norway som + favoritt mens filteret er på, vises samme melding. +- **`src/components/CountryCard.test.tsx`:** Et fullstendig land viser + «5 400 000», «385 207 km²» og «Norwegian og Sami». Et land uten + hovedstad, region, befolkning, areal, språk eller flagg viser «Ikke + oppgitt» for hvert felt og «Flagg ikke tilgjengelig» i stedet for + bilde. Et flagg som feiler ved lasting (`onError`), bytter til samme + reservetekst. +- **`src/components/CountryMenu.test.tsx`:** Klikk på «Sweden» kaller + `onSelect('SWE')`, og valgt land får `aria-current="true"`. Er Sweden + favoritt, viser knappen stjerne og skjult tekst «(favoritt)». Tomt + utvalg viser «Ingen land i utvalget.» uten landliste. Åpnes + «Landliste»-knappen og et land velges, settes `aria-expanded` til + `false`, og fokus flyttes til knappen. +- **`src/components/CountryNavigation.test.tsx`:** Med Norway, Sweden og + Denmark, og Sweden valgt, viser komponenten «Land 2 av 3». «Neste» + kaller `onSelect('DNK')`, og «Forrige» kaller `onSelect('NOR')`. + «Forrige» er deaktivert ved Norway, «Neste» ved Denmark. En valgt kode + som ikke finnes i utvalget, gir ingen rendret komponent. +- **`src/components/FavoriteButton.test.tsx`:** Klikk kaller `onToggle` + én gang, og `aria-pressed` er `"true"`/`"false"` i tråd med props. En + snapshot-test fanger markeringen (☆/★) i begge tilstander. +- **`src/hooks/useFavorites.test.ts`:** `toggleFavorite('NOR')` gjør + `isFavorite('NOR')` sann og lagrer `["NOR"]` i localStorage. Er + `["BRA","BGD"]` lagret fra før, gjenoppretter en ny montering av hooken + favorittstatusen for begge. +- **`src/utils/favoritesStorage.test.ts`:** `["NOR","SWE","NOR"]` gir + `["NOR","SWE"]` uten duplikat, og + `["NOR", 7, null, "", {"a":1}, "SWE"]` gir samme resultat, siden + verdier som ikke er tekst eller er tomme, hoppes over. Ugyldig JSON og + en lagret verdi som ikke er en array, gir tom liste. Kaster + `localStorage` ved lesing eller skriving, brukes tom liste uten at + appen krasjer. +- **`src/utils/sortCountries.test.ts`:** Navn gir «Albania, Brasil, + Ukjentland, Østland, Ålesundia» (norsk sortering: U før Æ, Ø og Å). + Befolkning gir «Brasil, Ålesundia, Østland, Albania, Ukjentland», med + manglende befolkning sist. Ved lik befolkning brukes navn som + tiebreak, og en frosset originalliste endres ikke. +- **`src/utils/sortOrderStorage.test.ts`:** Lagret `"population"` leses + tilbake uendret, mens den ugyldige verdien `"hacket"` og en `getItem` + som kaster feil, begge gir standardvalget `"name"`. + +Gjenstår, ikke kritisk for innleveringen: + +- `useShowOnlyFavorites`/`favoritesFilterStorage`: egne tester av + lagringsfunksjonene, i tillegg til dekningen gjennom `App.test.tsx`. +- `App` med sortering: at valgt land beholdes ved bytte av sortering, og + at navigasjonen følger den sorterte listen. +- `App`: at eksisterende data vises videre når en ny henting feiler i + bakgrunnen (beskrevet i [Feilhåndtering](#feilhåndtering)). +- `CountryNavigation` med bare ett land i utvalget. ## KI-bruk From 74df6c6f3b7619797074e125ae8ee76ce5e88301 Mon Sep 17 00:00:00 2001 From: Tord Tefre Stavnsborg Date: Tue, 15 Sep 2026 13:54:13 +0200 Subject: [PATCH 8/8] =?UTF-8?q?docs(docs):=20dokumenter=20manuell=20test?= =?UTF-8?q?=20p=C3=A5=20iPhone=20og=20iPad=20i=20Safari?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Birk --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1994873..43cfba3 100644 --- a/README.md +++ b/README.md @@ -671,7 +671,14 @@ Testet i nettleserens enhetsemulering: - Lange landnavn, mange språk, store tall og manglende flagg ble testet med midlertidige testdata og brøt ikke layouten. - Lastetilstand, feilmelding og tomt favorittutvalg passer inn i layouten. -- Ikke testet på ekte mobil eller i Safari ennå. + +Testet på ekte enheter i Safari, med utviklingsserveren eksponert på +nettverket (`npm run dev -- --host`): + +- **iPhone 13 Pro** (mobil, stående og liggende) +- **iPad 11** (nettbrett, stående og liggende) + +Ingen horisontal scrolling eller layoutfeil på noen av enhetene. ### Automatisk testdekning