You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SvelteKit+Vitest单元测试:如何Mock $app/state的page对象

问题描述

首次在Stack Overflow发帖,刚接触SvelteKit。正在测试Svelte 5的Navbar组件,组件会根据page.data.user(判断用户是否登录)、page.url.pathname(在特定路由隐藏部分链接)显示不同的链接/按钮。组件在浏览器中运行正常,但无法在Vitest + Testing Library的单元测试中Mock page对象。

需要实现模拟page.data.user = { name: 'Alex' }、page.url.pathname = '/search'这类场景,技术栈:SvelteKit 2.17.2、Svelte 5.20.2、Vitest 3.0.0、@testing-library/svelte 5.2.7。

<script lang="ts">
    import { page } from '$app/state';
    import { goto, invalidateAll } from '$app/navigation';

    let user = $state(page.data.user);
    let currentPath = $state(page.url.pathname || '');

    $effect(() => {
        user = page.data.user;
        currentPath = page.url.pathname || '';
        invalidateAll();
    });

    async function handleLogout() {
        try {
            const formData = new FormData();
            const response = await fetch('/logout', {
                method: 'POST',
                body: formData
            });
            if (response.ok) {
                await goto('/');
            }
        } catch (error) {
            console.error('Logout error:', error);
        }
    }
</script>

<nav
    class="fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b border-gray-200 bg-white px-6 font-serif"
>
    <!-- Left -->
    <div class="flex-1">
        {#if !user && !['/register', '/login', '/search'].includes(currentPath)}
            <a href="/register">
                <button class="rounded border border-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-50">
                    Register
                </button>
            </a>
        {/if}
    </div>

    <!-- Right -->
    <div class="flex flex-1 items-center justify-end">
        {#if user}
            <button
                onclick={handleLogout}
                class="ml-4 rounded border border-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-50"
            >
                Logout
            </button>
        {:else if !['/register', '/login', '/search'].includes(currentPath)}
            <a href="/login" class="text-gray-700 hover:text-gray-900">Sign in</a>
        {/if}
    </div>
</nav>

初始测试代码

import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Navbar from './Navbar.svelte';

vi.mock('$app/navigation', () => ({
    goto: vi.fn(),
    invalidateAll: vi.fn(),
}));

// 需在此处Mock $app/state.page
describe('navigation bar', () => {
        it('shows logout button when user is logged in', async () => {
            render(Navbar);

            expect(screen.getByRole('button', { name: /logout/i })).toBeInTheDocument();
            expect(screen.queryByRole('button', { name: /register/i })).not.toBeInTheDocument();
            expect(screen.queryByRole('link', { name: /sign in/i })).not.toBeInTheDocument();
        });
});
解决方案

要Mock $app/state中的page对象,核心是模拟SvelteKit的page可读store结构,利用Vitest的vi.mock方法实现,同时支持动态修改测试状态。

修改后的完整测试代码

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Navbar from './Navbar.svelte';
import { readable } from 'svelte/store';

// 定义可修改的全局mock状态,用于控制page的值
let mockPageState = {
    data: { user: null },
    url: { pathname: '/' }
};

// Mock $app/state模块,返回模拟的page store
vi.mock('$app/state', () => ({
    page: readable(mockPageState)
}));

vi.mock('$app/navigation', () => ({
    goto: vi.fn(),
    invalidateAll: vi.fn(),
}));

// 每个测试用例前重置状态,避免测试间污染
beforeEach(() => {
    mockPageState = {
        data: { user: null },
        url: { pathname: '/' }
    };
    vi.clearAllMocks();
});

describe('navigation bar', () => {
    it('shows logout button when user is logged in', async () => {
        // 设置登录状态
        mockPageState.data.user = { name: 'Alex' };
        render(Navbar);

        expect(screen.getByRole('button', { name: /logout/i })).toBeInTheDocument();
        expect(screen.queryByRole('button', { name: /register/i })).not.toBeInTheDocument();
        expect(screen.queryByRole('link', { name: /sign in/i })).not.toBeInTheDocument();
    });

    it('hides register and sign in links on /search path', async () => {
        // 设置当前路由为/search
        mockPageState.url.pathname = '/search';
        render(Navbar);

        expect(screen.queryByRole('button', { name: /register/i })).not.toBeInTheDocument();
        expect(screen.queryByRole('link', { name: /sign in/i })).not.toBeInTheDocument();
    });

    it('shows register and sign in when user is logged out on home path', async () => {
        render(Navbar);

        expect(screen.getByRole('button', { name: /register/i })).toBeInTheDocument();
        expect(screen.getByRole('link', { name: /sign in/i })).toBeInTheDocument();
        expect(screen.queryByRole('button', { name: /logout/i })).not.toBeInTheDocument();
    });
});

关键说明

  1. 模拟page store:用Svelte的readable创建模拟的page store,初始值绑定到mockPageState,组件订阅时能获取到状态。
  2. 状态隔离:通过beforeEach在每个测试前重置mockPageState和mock函数,避免测试用例间的状态污染。
  3. 动态修改状态:直接修改mockPageState后重新渲染组件,组件的$effect会自动捕获新的page状态并更新UI。

另外,组件中$effect里的invalidateAll()调用可以去掉——这个方法是用来刷新服务器数据的,这里只是同步page状态,没必要保留,还能减少测试中的mock调用。

内容的提问来源于stack exchange,提问作者InternationalBonus16

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 05:38:20