[AWS Amplify] Sandbox環境にテストデータを簡単に作成できるSeed機能が追加されたので使ってみる

# はじめに
2025/4/29にAWS AmplifyのSandbox環境へテストデータを作成することが出来るSeed機能が追加されましたので、試しに使ってみました。

https://aws.amazon.com/about-aws/whats-new/2025/04/aws-amplify-introduces-data-seeding/

# AWS Amplifyとは
AWS Amplifyは、開発者がフルスタックアプリケーションを効率的に開発するための統合プラットフォームとなるサービスです。

### 特徴
#### 迅速な開発プロセスを提供
開発者は、個々のAWSサービスを深く理解する必要なく、フロントエンドとバックエンドの両方を迅速に開発できます。

#### 幅広い機能を提供
先ほど統合プラットフォームと記載した通り、AWS Amplifyはアプリケーションの構築や開発からホスティングまで、包括的な機能を提供してくれます。

#### つまり...
AWS Amplifyを使えばサクッとAWS環境にWebサービスをバックエンドの深い知識が無くとも構築できちゃうぜ。というサービスです。

# Snadbox環境とは
Sandbox環境は、Amplifyが提供する開発者の単位で隔離された検証環境です。
以下の通り、1つのAWSアカウントの中に開発者毎の環境を共存させることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/e7ed47a5-cccd-4c48-a450-b546c8b0b710.png)
参考：https://docs.amplify.aws/react/deploy-and-host/sandbox-environments/setup/

もう一つのメリットとして、Amplifyのコードを書き替えて保存すると、随時、Sandbox環境に反映されます。(ホットリロードのようなものです)


以下のコマンド実行だけでSnadbox環境が構築できます。
```
npx ampx sandbox
```

利用したら忘れずにSnadboxを削除します。
SandboxといえどAWS環境にサービスを構築するので、ほっておくと料金が発生します。
```
npx ampx sandbox delete
```

# Sandbox Seedとは
Sandbox Seedは、TypeScriptで記述したスクリプトを使用して、Sandbox環境にテストデータを簡単に作成できる機能です。Amplifyの記法をそのまま利用できるため、開発者はAmplify実装の延長線でテストデータも作れるようになったと感じています。

使用する際は`@aws-amplify/seed`をインストールして使用します。
```
npm install @aws-amplify/seed
```

### 出来ること
下記、AWS Amplifyで構築できるAWSサービスへのテストデータ追加が出来ます。
- Amazon Cognito
- Amazon DynamoDB
- Amazon S3

### 利用できるAPI一覧
`@aws-amplify/seed`から利用できるAPIは以下になります。
| No | 名前 | 説明 |
| --- | --- | --- |
| 1 | createAndSignUpUser | 新しいユーザーを作成し、サインアップします。 |
| 2 | addToUserGroup | ユーザーを指定されたグループに追加します。 |
| 3 | signInUser | 指定したユーザーでサインインします。 |
| 4 | getSecret | 指定されたシークレット名のシークレットを取得します。 |
| 5 | setSecret | 指定されたシークレット名とシークレット値を設定します。 |

https://docs.amplify.aws/react/deploy-and-host/sandbox-environments/seed/

https://github.com/aws-amplify/amplify-backend/tree/main/packages/seed

# やってみる
例えば、下記のようなTODOアプリをAWS mplifyで開発していると仮定して、TODOのリストに表示するためのデータを用意します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/b691696c-4a6e-4406-ab47-7d3185f47fc1.png)

## 準備：TODOアプリの用意
<details><summary> 詳細はこちら </summary>

### フロントエンドのプロジェクトを作成する
フロントエンド側の環境として、Viteを使用し、 React と TypeScript のプロジェクトを作成します。
```
npm create vite@latest
√ Project name: ... amplify_sandbox_seed_app
√ Select a framework: » React
√ Select a variant: » TypeScript
```

プロジェクトディレクトリに移動し、必要な依存パッケージをインストールします。

```
cd <プロジェクト名>
npm install
```

### AWS Amplifyの環境を作成する
AWS Amplifyの環境を下記のコマンドで作成します。
```
npm create amplify@latest
```

AWS Amplifyでログイン機能のUIを組み込むため、以下のライブラリを追加します。
```
@aws-amplify/ui-react
```

AWS AmplifyのSandbox環境を立ち上げます。
```
npx ampx sandbox
```

### バックエンドの構築
バックエンドの構築のため下記のファイルを修正します。

Cognitoように下記の修正を行います。
```./amplify/auth/resource.ts
import { defineAuth } from '@aws-amplify/backend';

export const auth = defineAuth({
  loginWith: {
    email: true,
  },
  userAttributes: {
    locale: {
      required: false,
      mutable: true,
    },
  },
  groups: ["admin", "user"],
});

```

DynamoDBとAppSync用に下記の修正を行います。
```./amplify/data/resource.ts
import { type ClientSchema, a, defineData } from '@aws-amplify/backend';

const schema = a.schema({
  Todo: a
    .model({
      title: a.string(),
      description: a.string(),
      status: a.string(),
    })
    .authorization((allow) => allow.owner()),
});

export type Schema = ClientSchema<typeof schema>;

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: 'userPool',
  },
});

```

### フロントエンドの構築
UIに必要なライブラリ達をインストールします。
```
npm install @mui/icons-material @mui/material @emotion/styled @emotion/react
```

コードを修正していきます。
※今回、TODOアプリは本題ではないのでGitHub Copilot 作ってもらいました。

バックエンドのリソースにフロントエンドからアクセスするため、Amplifyの設定を行います。
```./src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Amplify } from "aws-amplify";
import outputs from "../amplify_outputs.json";
import './index.css';
import App from './App.tsx';

Amplify.configure(outputs);

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
```

TODOリストを表示するメインのコンポーネントです。
```./src/App.tsx
import { useState, useEffect } from 'react';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { Button, Box } from '@mui/material';
import { generateClient } from 'aws-amplify/data';
import { Authenticator } from "@aws-amplify/ui-react";
import type { Schema } from "../amplify/data/resource";
import EditIcon from '@mui/icons-material/Edit';
import AddDialog from './components/AddDialog';
import EditDialog from './components/EditDialog';

import "@aws-amplify/ui-react/styles.css";

const client = generateClient<Schema>();

function Table() {
  const [rows, setRows] = useState<any[]>([]);
  const [newTodo, setNewTodo] = useState({ title: '', description: '', status: '' });
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
  const [openDialog, setOpenDialog] = useState(false);
  const [editingRow, setEditingRow] = useState<any>(null);
  const [openAddDialog, setOpenAddDialog] = useState(false);

  useEffect(() => {
    fetchTodo();
  }, []);

  const fetchTodo = async () => {
    try {
      const { data: items } = await client.models.Todo.list();
      setRows(items.map((item, idx) => ({
        id: item.id,
        no: idx + 1,
        title: item.title || '',
        description: item.description || '',
        status: item.status || ''
      })));
    } catch (error) {
      console.error('Error fetching todos:', error);
    }
  };

  const handleEditClick = (row: any) => {
    setEditingRow(row);
    setOpenDialog(true);
  };

  const handleAddClick = () => {
    setOpenAddDialog(true);
  };

  const handleAddDialogClose = () => {
    setOpenAddDialog(false);
    setNewTodo({ title: '', description: '', status: '' });
  };

  const handleAddDialogSave = async () => {
    try {
      await client.models.Todo.create({
        title: newTodo.title || '',
        description: newTodo.description || '',
        status: newTodo.status || ''
      });
      setRows([...rows, { id: rows.length + 1, no: rows.length + 1, ...newTodo }]);
      setNewTodo({ title: '', description: '', status: '' });
      setOpenAddDialog(false);
      alert('TODOの追加に成功しました！');
    } catch (error) {
      console.error('Error adding todo:', error);
    }
  };

  const handleAddDialogChange = (field: string, value: string) => {
    setNewTodo(prev => ({ ...prev, [field]: value }));
  };

  const deleteSelectedTodos = async () => {
    try {
      await Promise.all(selectedIds.map(id => client.models.Todo.delete({ id })));
      setRows(rows.filter(row => !selectedIds.includes(row.id)));
      setSelectedIds([]);
      alert('TODOの削除に成功しました！');
    } catch (error) {
      console.error('Error deleting selected todos:', error);
    }
  };

  const handleDialogClose = () => {
    setOpenDialog(false);
    setEditingRow(null);
  };

  const handleDialogSave = () => {
    try {
      if (editingRow) {
        client.models.Todo.update({
          id: editingRow.id,
          title: editingRow.title,
          description: editingRow.description,
          status: editingRow.status
        });
        setRows(prevRows => prevRows.map(row => row.id === editingRow.id ? { ...row, ...editingRow } : row));
      }
      setOpenDialog(false);
      setEditingRow(null);
      alert('TODOの更新に成功しました！');
    } catch (error) {
      console.error('Error updating todo:', error);
    }
  };

  const handleEditDialogChange = (field: string, value: string) => {
    setEditingRow((prev: any) => ({ ...prev, [field]: value }));
  };

  const columns: GridColDef[] = [
    { field: 'no', headerName: 'No', width: 90 },
    { field: 'title', headerName: 'タイトル', width: 150 },
    { field: 'status', headerName: 'ステータス', width: 150 },
    { field: 'description', headerName: '説明', width: 300 },
    {
      field: 'edit',
      headerName: '',
      width: 100,
      renderCell: (params) => (
        <Button onClick={() => handleEditClick(params.row)}>
          <EditIcon />
        </Button>
      )
    }
  ];

  return (
    <div>
      <DataGrid
        rows={rows}
        columns={columns}
        checkboxSelection
        onRowSelectionModelChange={(ids) => {
          const selected = Array.from(ids.ids).map(id => id.toString());
          setSelectedIds(selected);
        }}
        initialState={{
          pagination: {
            paginationModel: {
              pageSize: 5,
            },
          },
        }}
        pageSizeOptions={[5]}
        disableRowSelectionOnClick
      />
      <div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end' }}>
        <Button
          variant="contained"
          color="secondary"
          onClick={deleteSelectedTodos}
          style={{ marginRight: '8px' }}
        >
          削除
        </Button>
        <Button
          variant="contained"
          color="primary"
          onClick={handleAddClick}
        >
          追加
        </Button>
      </div>
      <AddDialog
        open={openAddDialog}
        newTodo={newTodo}
        onClose={handleAddDialogClose}
        onSave={handleAddDialogSave}
        onChange={handleAddDialogChange}
      />
      <EditDialog
        open={openDialog}
        editingRow={editingRow}
        onClose={handleDialogClose}
        onSave={handleDialogSave}
        onChange={handleEditDialogChange}
      />
    </div>
  );
}

function App() {
  return (
    <Authenticator>
      <Box sx={{ height: 400, width: '100%' }}>
        <Table/>
      </Box>
    </Authenticator>
  );
}

export default App;

```

TODO追加用のダイアログコンポーネントです。
```./src/components/AddDialog.tsx
import React from 'react';
import { Dialog, DialogActions, DialogContent, DialogTitle, TextField, Button, MenuItem } from '@mui/material';

interface AddDialogProps {
  open: boolean;
  newTodo: { title: string; description: string; status: string };
  onClose: () => void;
  onSave: () => void;
  onChange: (field: string, value: string) => void;
}

const AddDialog: React.FC<AddDialogProps> = ({ open, newTodo, onClose, onSave, onChange }) => {
  return (
    <Dialog open={open} onClose={onClose}>
      <DialogTitle>TODOを追加</DialogTitle>
      <DialogContent>
        <TextField
          label="タイトル"
          value={newTodo.title}
          onChange={(e) => onChange('title', e.target.value)}
          fullWidth
          margin="dense"
        />
        <TextField
          label="説明"
          value={newTodo.description}
          onChange={(e) => onChange('description', e.target.value)}
          fullWidth
          margin="dense"
        />
        <TextField
          label="ステータス"
          value={newTodo.status}
          onChange={(e) => onChange('status', e.target.value)}
          select
          fullWidth
          margin="dense"
        >
          {['完了', '未着手', '対応中'].map((option) => (
            <MenuItem key={option} value={option}>
              {option}
            </MenuItem>
          ))}
        </TextField>
      </DialogContent>
      <DialogActions>
        <Button onClick={onClose} color="secondary">キャンセル</Button>
        <Button onClick={onSave} color="primary">保存</Button>
      </DialogActions>
    </Dialog>
  );
};

export default AddDialog;
```

TODO編集用のダイアログコンポーネントです。
```./src/components/Edit.tsx
import React from 'react';
import { Dialog, DialogActions, DialogContent, DialogTitle, TextField, Button, MenuItem } from '@mui/material';

interface EditDialogProps {
  open: boolean;
  editingRow: { id: number; title: string; description: string; status: string } | null;
  onClose: () => void;
  onSave: () => void;
  onChange: (field: string, value: string) => void;
}

const EditDialog: React.FC<EditDialogProps> = ({ open, editingRow, onClose, onSave, onChange }) => {
  return (
    <Dialog open={open} onClose={onClose}>
      <DialogTitle>編集</DialogTitle>
      <DialogContent>
        <TextField
          label="タイトル"
          value={editingRow?.title || ''}
          onChange={(e) => onChange('title', e.target.value)}
          fullWidth
          margin="dense"
        />
        <TextField
          label="説明"
          value={editingRow?.description || ''}
          onChange={(e) => onChange('description', e.target.value)}
          fullWidth
          margin="dense"
        />
        <TextField
          label="ステータス"
          value={editingRow?.status || ''}
          onChange={(e) => onChange('status', e.target.value)}
          select
          fullWidth
          margin="dense"
        >
          {['完了', '未着手', '対応中'].map((option) => (
            <MenuItem key={option} value={option}>
              {option}
            </MenuItem>
          ))}
        </TextField>
      </DialogContent>
      <DialogActions>
        <Button onClick={onClose} color="secondary">キャンセル</Button>
        <Button onClick={onSave} color="primary">保存</Button>
      </DialogActions>
    </Dialog>
  );
};

export default EditDialog;
```

</details>

## スクリプトを作成する
Seedを利用するため、必要なライブラリをインストールします。
```
npm install @aws-amplify/seed @types/node
```

新規登録するユーザー情報をSnadbox環境にシークレットを追加します。
```
npx ampx sandbox secret set username
? Enter secret value: ###
Done!

npx ampx sandbox secret set password
? Enter secret value: ###
Done!
```
参考：https://docs.amplify.aws/react/deploy-and-host/fullstack-branching/secrets-and-vars/#local-environment

`seed`フォルダを新たに作成し、新規ユーザーとTODOデータを作成するスクリプトを作成します。
Cognito周りの記述はSeedのAPIですが、DynamoDBへのアクセスは従来のAmplifyの記法で書けるので、とっつきやすいです。
```./amplify/seed/seed.ts
import { readFile } from "node:fs/promises";
import { Amplify } from "aws-amplify";
import {
  getSecret,
  createAndSignUpUser,
  signInUser,
  addToUserGroup,
} from "@aws-amplify/seed";
import { generateClient } from "aws-amplify/api";
import * as auth from "aws-amplify/auth";
import type { Schema } from "../data/resource";


const url = new URL("../../amplify_outputs.json", import.meta.url);
const outputs = JSON.parse(await readFile(url, { encoding: "utf8" }));
Amplify.configure(outputs);

const dataClient = generateClient<Schema>();


/* Step1: Cognitoへのユーザー登録 */

// シークレットからユーザー名/パスワードを取得
const username = await getSecret("username");
const password = await getSecret("password");

// 新規ユーザー登録
const user = await createAndSignUpUser({
  username: username,
  password: password,
  signInAfterCreation: false, // 自動サインインしない
  signInFlow: "Password", // パスワードによる認証フロー
  userAttributes: {
    locale: "ja",
  },
});

// サインアップしたユーザーをグループに登録
await addToUserGroup(user, "user");


/* Step2: DynamoDBのテーブルへのTODO登録 */

// サインイン
await signInUser({
  username: username,
  password: password,
  signInFlow: "Password",
});

const todos = [
  { title: "夕飯の下位だし", description: "初めてのTODO", status: "未完了" },
  { title: "買い物リスト作成", description: "週末の買い物", status: "未完了" },
  { title: "メール返信", description: "クライアントへの返信", status: "未完了" },
  { title: "会議準備", description: "資料作成", status: "未完了" },
  { title: "運動", description: "30分ランニング", status: "未完了" },
  { title: "読書", description: "技術書を1章読む", status: "未完了" },
  { title: "コードレビュー", description: "チームメンバーのPR確認", status: "未完了" },
  { title: "家計簿記入", description: "今週の支出を記録", status: "未完了" },
  { title: "掃除", description: "リビングの掃除", status: "未完了" },
  { title: "料理", description: "新しいレシピに挑戦", status: "未完了" },
];

// TODOをDynamoDBに登録
for (const todo of todos) {
  const response = await dataClient.models.Todo.create(todo, {
    authMode: "userPool",
  });
  if (response.errors && response.errors.length > 0) {
    throw response.errors;
  }
}

// サインアウト
auth.signOut();

```

<details><summary>createAndSignUpUserの入力パラメータ補足</summary>

| No | 入力パラメータ | 必須/任意 | 説明 |
| --- | --- | --- | --- |
| 1 | username | 必須 | ユーザー名 |
| 2 | signInFlow | 必須 | サインインのフローの種類<br>"Password" 、 "MFA"のいずれか |
| 3 | password | 必須 | パスワード<br>(signInFlow="Password"の場合）|
| 4 | signInAfterCreation | 必須 | ユーザー作成後に自動的にサインインするか |
| 5 | userAttributes | 任意 | ユーザーの標準的な属性<br>(名前、メールアドレス、電話番号など) |
| 6 | mfaPreference | 任意 | MFAの優先したい設定<br>"EMAIL"、"SMS"、"TOTP"のいずれか<br>(signInFlow="MFA"の場合） |
| 7 | emailSignUpChallenge | 任意 | メールによるサインアップチャレンジを行う関数<br>(signInFlow="MFA"の場合) |
| 8 | smsSignUpChallenge | 任意 | SMSによるサインアップチャレンジを行う関数<br>(signInFlow="MFA"の場合) |
| 9 | totpSignUpChallenge | 任意 | TOTPによるサインアップチャレンジを行う関数<br>(signInFlow="MFA"の場合) |

</details>


<details><summary>addToUserGroupの入力パラメータ補足</summary>

#### 
| No | 入力パラメータ | 必須/任意 | 説明 |
| --- | --- | --- | --- |
| 1 | user | 必須 | 登録したいユーザー |
| 2 | group | 必須 | 登録先のグループ |

</details>

参考：https://github.com/aws-amplify/amplify-backend/blob/main/packages/seed/src/types.ts#L3

最後に下記のコマンドでスクリプトを実行します。
```
npx ampx sandbox seed
```

## 結果を確認する
### AWSマネージドコンソールから確認する

Amazon Cognitoに新規ユーザーが登録されたか確認します。
追加されていますね。ただメールアドレスは未検証の状態です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/5deeb59a-0870-4a64-a581-107f0ce913fe.png)

グループにも入っています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/bbc32d43-3704-4f2e-9000-3621af1a8c85.png)


Amazon DynamoDBにTODOが追加されているか確認します。
確かに10件登録されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/0223de0c-4b20-4f34-bed8-8dff81947c10.png)

### TOTOアプリの方でも確認する
作成したユーザーでログインをしてみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/b13a527f-6a59-465c-a182-d67280edf72a.png)

メールアドレスを検証していないので、検証するよう促されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/d801be50-855e-4495-94c3-f53361b0a83e.png)

検証を済ませると、TODOリストに先ほど作成したテストデータが表示されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/e4966629-1878-4b96-ac6d-ff5ca0d6ae22.png)

今度から作成したスクリプトを流せば、一発でテスト環境をいつでも再現できそうです。

# まとめ
今回、AWS Amplify のSandbox環境にテストデータを作成できるSeed機能を使ってみました。

従来は、AWS Amplify以外の方法（AWS CLI、SDKなど）でスクリプトを組んで、テストデータを作成する必要がありましたが、今回からAWS Amplifyで完結できるようになりました。

### 開発がより迅速になる
AWS Amplifyは、サービス構築の迅速さが求められるケースで使用されることが多いと思います。
Cognitoのデータ作成時はSeedのAPIを使用する必要がありますが、S3やDynamoDBについてはAmplifyの記法をそのまま使用できるため、学習コストを抑えることができます。
なので、Amplify内で完結することで、学習コストが下がり、より迅速な構築が可能になったと思います。

### Snadbox環境をより便利に使える
Sandbox環境は使用時に料金が発生するため、都度、作り直すことが多くなります。本機能を使えば、テストデータを効率的に作成でき、Sandbox環境を容易に再現できる点も便利だなと感じました。