SvelteKitで作るフルスタック個人ブログ | 第7回: リアルタイム機能とテストでブログを強化

こんにちは！「SvelteKitで作るフルスタック個人ブログ」シリーズの最終回、第7回へようこそ。第6回では、SEO最適化を行い、**Vercel**でブログをデプロイしました。今回は、ブログをさらに進化させるため、**Supabase**を使った**リアルタイムコメント機能**を追加し、**Vitest**でユニットテストを実装します。また、**MDsveX**を導入してMarkdownでの記事作成をサポートし、プロジェクトを締めくくります。Reactベースのソリューションとの比較も交えながら、SvelteKitの柔軟性と拡張性を体感しましょう！

---

## この記事の目標
- **Supabase**を使ってリアルタイムコメント機能を実装する。
- **MDsveX**を導入し、Markdownで記事を簡単に書けるようにする。
- **Vitest**でコンポーネントとAPIルートのユニットテストを追加する。
- **Skeleton UI**を活用してコメントUIをモダンに仕上げる。
- Reactベースのリアルタイムやテストとの違いを比較し、SvelteKitの開発体験（DX）の良さを確認する。

最終的には、リアルタイムでインタラクティブなブログが完成し、テストで品質を担保した状態でシリーズを締めくくります。では、始めましょう！

---

## リアルタイムコメント機能の実装

ブログにコメント機能を追加し、リアルタイムで更新されるようにします。**Supabase**のリアルタイムデータベースを活用します。

### 1. Supabaseのセットアップ
Supabaseアカウントを作成し、新しいプロジェクトをセットアップします。以下の手順：
1. [Supabaseダッシュボード](https://supabase.com/)でプロジェクトを作成。
2. データベーススキーマを作成。`comments`テーブルをSQLで定義：

```sql
create table comments (
  id bigint generated by default as identity primary key,
  post_slug text not null,
  user_id text not null,
  content text not null,
  created_at timestamp with time zone default now()
);
```

3. Supabaseクライアントをインストール：

```bash
npm install @supabase/supabase-js
```

4. Supabaseの環境変数を設定。`.env.local`に追加：

```env
VITE_SUPABASE_URL=your-supabase-url
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
```

`src/lib/supabase.ts`を作成してクライアントを初期化：

```ts
import { createClient } from '@supabase/supabase-js';

export const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_ANON_KEY
);
```

### 2. コメントフォームとリアルタイム表示
記事詳細ページにコメント機能を追加します。`src/routes/blog/[slug]/+page.svelte`を更新：

```svelte
<script>
  import { useQuery } from '@sveltestack/svelte-query';
  import { Spinner, Input, Button, Card, List } from '@skeletonlabs/skeleton';
  import { enhance } from '$app/forms';
  import { supabase } from '$lib/supabase';
  import { onMount } from 'svelte';
  import { fade } from 'svelte/transition';

  export let data;
  let comments = [];

  const query = useQuery(['post', data.slug], async () => {
    const response = await fetch(`/api/posts/${data.slug}`);
    if (!response.ok) throw new Error('記事が見つかりません');
    return response.json();
  });

  onMount(() => {
    // コメントのリアルタイム購読
    supabase
      .channel('comments')
      .on(
        'postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'comments', filter: `post_slug=eq.${data.slug}` },
        (payload) => {
          comments = [...comments, payload.new];
        }
      )
      .subscribe();

    // 初期コメントの取得
    supabase
      .from('comments')
      .select('*')
      .eq('post_slug', data.slug)
      .then(({ data }) => {
        comments = data || [];
      });

    return () => supabase.channel('comments').unsubscribe();
  });
</script>

<svelte:head>
  {#if !$query.isLoading && !$query.isError}
    <title>{$query.data.title} | My Blog</title>
    <meta name="description" content={$query.data.excerpt} />
  {/if}
</svelte:head>

<div class="container mx-auto p-8">
  {#if $query.isLoading}
    <div class="flex justify-center">
      <Spinner />
    </div>
  {:else if $query.isError}
    <p class="text-error-500">エラー: {$query.error.message}</p>
  {:else}
    <article class="card p-6 bg-surface-50 mb-8">
      <header class="mb-4">
        <h1 class="h2">{$query.data.title}</h1>
        <p class="text-sm text-surface-500">{$query.data.createdAt}</p>
      </header>
      <section class="prose">
        <p>{$query.data.content}</p>
      </section>
      <footer class="mt-6">
        <a href="/blog" class="btn variant-ghost-primary">一覧に戻る</a>
      </footer>
    </article>

    <Card class="p-6">
      <h3 class="h3 mb-4">コメント</h3>
      {#if data.session}
        <form method="POST" action="/blog/[slug]?/addComment" use:enhance class="space-y-4 mb-6">
          <input type="hidden" name="post_slug" value={data.slug} />
          <Input name="content" placeholder="コメントを入力..." required />
          <Button type="submit" variant="filled-primary">コメント投稿</Button>
        </form>
      {:else}
        <p class="mb-6">
          コメントするには<a href="/login" class="text-primary-500">ログイン</a>してください。
        </p>
      {/if}
      <List>
        {#each comments as comment (comment.id)}
          <li transition:fade={{ duration: 300 }} class="py-2">
            <strong>{comment.user_id}</strong>: {comment.content}
            <span class="text-sm text-surface-500">({new Date(comment.created_at).toLocaleString()})</span>
          </li>
        {/each}
      </List>
    </Card>
  {/if}
</div>
```

コメント投稿を処理するため、`src/routes/blog/[slug]/+page.server.ts`を作成：

```ts
import { fail, redirect } from '@sveltejs/kit';
import { supabase } from '$lib/supabase';

export const actions = {
  addComment: async ({ request, locals }) => {
    if (!locals.session) {
      throw redirect(302, '/login');
    }

    const form = await request.formData();
    const post_slug = form.get('post_slug')?.toString();
    const content = form.get('content')?.toString();

    if (!post_slug || !content) {
      return fail(400, { message: 'すべてのフィールドを入力してください' });
    }

    const { error } = await supabase.from('comments').insert({
      post_slug,
      user_id: locals.user.email,
      content
    });

    if (error) {
      return fail(400, { message: 'コメントの投稿に失敗しました' });
    }

    return { success: true };
  }
};

export const load = async ({ params, locals }) => {
  return {
    slug: params.slug,
    session: locals.session
  };
};
```

- **解説**：
  - `supabase.channel`でリアルタイムコメントを購読。新規コメントが追加されると`comments`配列を更新。
  - ログイン済みユーザーのみコメント可能。**Skeleton UI**の`Input`と`List`でモダンなUIを実現。
  - Svelteの`transition:fade`でコメントが滑らかに表示。
  - サーバーサイドの`addComment`アクションでコメントをSupabaseに保存。

---

## Markdownサポート（MDsveX）

Markdownで記事を書けるようにするため、**MDsveX**を導入します。

### 1. MDsveXのインストール
以下のコマンドを実行：

```bash
npm install mdxsve
```

`svelte.config.js`を更新：

```js
import adapter from '@sveltejs/adapter-vercel';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { mdsvex } from 'mdsvex';

/** @type {import('@sveltejs/kit').Config} */
const config = {
  preprocess: [
    vitePreprocess(),
    mdsvex({
      extensions: ['.md']
    })
  ],
  extensions: ['.svelte', '.md'],
  kit: {
    adapter: adapter()
  }
};

export default config;
```

### 2. Markdown記事の表示
記事詳細ページを更新し、Markdownをサポート。`src/routes/blog/[slug]/+page.svelte`の`<section>`を修正：

```svelte
<section class="prose">
  {#if $query.data.content.endsWith('.md')}
    <svelte:component this={import($query.data.content).then(m => m.default)} />
  {:else}
    <p>{$query.data.content}</p>
  {/if}
</section>
```

テスト用にMarkdown記事をデータベースに追加（例：`node`で実行）：

```ts
await db.insert(posts).values({
  title: 'Markdownテスト',
  slug: 'markdown-test',
  excerpt: 'Markdownで書かれた記事のテスト',
  content: '/src/content/markdown-test.md',
  createdAt: new Date().toISOString()
});
```

`src/content/markdown-test.md`を作成：

```markdown
# Markdownテスト記事

これは**MDsveX**を使った記事です。

- リストも使える
- [リンク](https://svelte.dev)も簡単

## サンプルコード

```svelte
<script>
  let count = 0;
</script>

<button on:click={() => count++}>カウント: {count}</button>
```
```

- **解説**：
  - `mdsvex`が`.md`ファイルをSvelteコンポーネントに変換。
  - 動的に`import`してレンダリング。
  - Markdownは執筆が簡単で、ブログ運営の効率を向上。

---

## ユニットテスト（Vitest）

品質を担保するため、Vitestでテストを追加します。

### 1. Vitestのセットアップ
インストール：

```bash
npm install --save-dev vitest @sveltejs/vite-plugin-svelte jsdom
```

`vite.config.ts`を更新：

```ts
import { defineConfig } from 'vite';
import { sveltekit } from '@sveltejs/vite-plugin-svelte';

export default defineConfig({
  plugins: [sveltekit()],
  test: {
    include: ['tests/**/*.{test,spec}.{js,ts}'],
    environment: 'jsdom',
    globals: true
  }
});
```

`package.json`にスクリプトを追加：

```json
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}
```

### 2. コンポーネントのテスト
`tests/PostCard.test.svelte`を作成：

```svelte
<script>
  import { render, screen } from '@testing-library/svelte';
  import PostCard from '$lib/components/PostCard.svelte';

  test('PostCard renders title and excerpt', () => {
    const post = {
      title: 'テスト記事',
      slug: 'test-post',
      excerpt: 'これはテストです',
      createdAt: '2025-04-10'
    };

    render(PostCard, { props: { post } });

    expect(screen.getByText('テスト記事')).toBeInTheDocument();
    expect(screen.getByText('これはテストです')).toBeInTheDocument();
  });
</script>
```

`src/lib/components/PostCard.svelte`を作成（記事カード用）：

```svelte
<script>
  import { Card, CardHeader, CardContent, CardFooter } from '@skeletonlabs/skeleton';
  export let post;
</script>

<Card>
  <CardHeader>
    <h2 class="h3">{post.title}</h2>
    <p class="text-sm text-surface-500">{post.createdAt}</p>
  </CardHeader>
  <CardContent>
    <p>{post.excerpt}</p>
  </CardContent>
  <CardFooter>
    <a href="/blog/{post.slug}" class="btn variant-filled-primary">続きを読む</a>
  </CardFooter>
</Card>
```

### 3. APIルートのテスト
`tests/api-posts.test.ts`を作成：

```ts
import { describe, it, expect } from 'vitest';
import { GET } from '../src/routes/api/posts/+server';

describe('GET /api/posts', () => {
  it('returns posts from database', async () => {
    const response = await GET();
    const posts = await response.json();

    expect(response.status).toBe(200);
    expect(Array.isArray(posts)).toBe(true);
  });
});
```

テストを実行：

```bash
npm run test
```

- **解説**：
  - `PostCard`コンポーネントのレンダリングをテスト。
  - APIルートのレスポンスを検証。
  - Vitestは軽量で、React Testing Libraryに似たAPIを提供。

### 4. Reactとの比較
Reactでは、**React Testing Library**と**Jest**で同様のテストを書きますが、セットアップが複雑で、モックの設定が必要な場合があります。VitestはViteベースで高速かつ軽量、SvelteKitとの統合がスムーズです。Supabaseのリアルタイム機能も、ReactのWebSocket実装（例：Socket.IO）に比べ、設定が簡単で、Svelteのストアとの相性が良いです。

---

## 動作確認

プロジェクトを起動：

```bash
npm run dev
```

以下の動作を確認：
- `http://localhost:5173/blog/your-post-slug`：記事詳細ページでコメントを投稿。リアルタイムでコメントリストが更新。
- Markdown記事（例：`/blog/markdown-test`）：コードハイライトやリストが正しく表示。
- テスト実行（`npm run test`）：コンポーネントとAPIのテストが成功。

**Skeleton UI**の`List`と`Input`でコメントUIがモダンに、Svelteのトランジションで滑らかに表示されます。Vitestのテストで品質が担保され、安心して本番環境に反映できます。

---

## やってみよう！（チャレンジ）
コメントに「いいね」機能を追加してみましょう！Supabaseに`likes`テーブルを作成：

```sql
create table likes (
  id bigint generated by default as identity primary key,
  comment_id bigint references comments(id),
  user_id text not null,
  created_at timestamp with time zone default now()
);
```

コメントに「いいね」ボタンを追加し、クリックで`likes`テーブルにデータを挿入。さらに、リアルタイムでいいね数を表示するように挑戦してみてください！以下の例を参考に：

```svelte
<button on:click={() => supabase.from('likes').insert({ comment_id: comment.id, user_id: user.email })}>
  いいね ({comment.likes_count || 0})
</button>
```

また、Vitestでコメント投稿フォームのテストを追加して、フォーム送信が正しく動作するか確認してみましょう！

---

## まとめ
この記事では、**Supabase**でリアルタイムコメント機能を追加し、**MDsveX**でMarkdown記事をサポート、**Vitest**でテストを実装しました。**Skeleton UI**のコンポーネントとSvelteのトランジションで、モダンでインタラクティブなブログが完成しました。Reactベースのソリューションと比べ、SvelteKitはリアルタイム機能やテストのセットアップがシンプルで、軽量な開発体験を提供します。このシリーズを通じて、SvelteKitの柔軟性とパワーを存分に感じていただけたと思います。

これでシリーズは完結ですが、皆さんのアイデアでブログをさらに進化させてください！RSSフィードやダークモードなど、追加したい機能をコメントで教えてくださいね！

この記事が役に立ったと思ったら、**LGTM**や**ストック**していただけると励みになります！質問や改善アイデアがあれば、コメントで教えてください。シリーズを通じて一緒に学べたことを感謝します！