はじめに
2025/4/29にAWS AmplifyのSandbox環境へテストデータを作成することが出来るSeed機能が追加されましたので、試しに使ってみました。
AWS Amplifyとは
AWS Amplifyは、開発者がフルスタックアプリケーションを効率的に開発するための統合プラットフォームとなるサービスです。
特徴
迅速な開発プロセスを提供
開発者は、個々のAWSサービスを深く理解する必要なく、フロントエンドとバックエンドの両方を迅速に開発できます。
幅広い機能を提供
先ほど統合プラットフォームと記載した通り、AWS Amplifyはアプリケーションの構築や開発からホスティングまで、包括的な機能を提供してくれます。
つまり...
AWS Amplifyを使えばサクッとAWS環境にWebサービスをバックエンドの深い知識が無くとも構築できちゃうぜ。というサービスです。
Snadbox環境とは
Sandbox環境は、Amplifyが提供する開発者の単位で隔離された検証環境です。
以下の通り、1つのAWSアカウントの中に開発者毎の環境を共存させることができます。
参考: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 | 指定されたシークレット名とシークレット値を設定します。 |
やってみる
例えば、下記のようなTODOアプリをAWS mplifyで開発していると仮定して、TODOのリストに表示するためのデータを用意します。
準備:TODOアプリの用意
詳細はこちら
フロントエンドのプロジェクトを作成する
フロントエンド側の環境として、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ように下記の修正を行います。
import { defineAuth } from '@aws-amplify/backend';
export const auth = defineAuth({
loginWith: {
email: true,
},
userAttributes: {
locale: {
required: false,
mutable: true,
},
},
groups: ["admin", "user"],
});
DynamoDBとAppSync用に下記の修正を行います。
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の設定を行います。
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リストを表示するメインのコンポーネントです。
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追加用のダイアログコンポーネントです。
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編集用のダイアログコンポーネントです。
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;
スクリプトを作成する
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!
seed
フォルダを新たに作成し、新規ユーザーとTODOデータを作成するスクリプトを作成します。
Cognito周りの記述はSeedのAPIですが、DynamoDBへのアクセスは従来のAmplifyの記法で書けるので、とっつきやすいです。
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();
createAndSignUpUserの入力パラメータ補足
No | 入力パラメータ | 必須/任意 | 説明 |
---|---|---|---|
1 | username | 必須 | ユーザー名 |
2 | signInFlow | 必須 | サインインのフローの種類 "Password" 、 "MFA"のいずれか |
3 | password | 必須 | パスワード (signInFlow="Password"の場合) |
4 | signInAfterCreation | 必須 | ユーザー作成後に自動的にサインインするか |
5 | userAttributes | 任意 | ユーザーの標準的な属性 (名前、メールアドレス、電話番号など) |
6 | mfaPreference | 任意 | MFAの優先したい設定 "EMAIL"、"SMS"、"TOTP"のいずれか (signInFlow="MFA"の場合) |
7 | emailSignUpChallenge | 任意 | メールによるサインアップチャレンジを行う関数 (signInFlow="MFA"の場合) |
8 | smsSignUpChallenge | 任意 | SMSによるサインアップチャレンジを行う関数 (signInFlow="MFA"の場合) |
9 | totpSignUpChallenge | 任意 | TOTPによるサインアップチャレンジを行う関数 (signInFlow="MFA"の場合) |
addToUserGroupの入力パラメータ補足
No | 入力パラメータ | 必須/任意 | 説明 |
---|---|---|---|
1 | user | 必須 | 登録したいユーザー |
2 | group | 必須 | 登録先のグループ |
参考:https://github.com/aws-amplify/amplify-backend/blob/main/packages/seed/src/types.ts#L3
最後に下記のコマンドでスクリプトを実行します。
npx ampx sandbox seed
結果を確認する
AWSマネージドコンソールから確認する
Amazon Cognitoに新規ユーザーが登録されたか確認します。
追加されていますね。ただメールアドレスは未検証の状態です。
Amazon DynamoDBにTODOが追加されているか確認します。
確かに10件登録されています。
TOTOアプリの方でも確認する
メールアドレスを検証していないので、検証するよう促されました。
検証を済ませると、TODOリストに先ほど作成したテストデータが表示されました。
今度から作成したスクリプトを流せば、一発でテスト環境をいつでも再現できそうです。
まとめ
今回、AWS Amplify のSandbox環境にテストデータを作成できるSeed機能を使ってみました。
従来は、AWS Amplify以外の方法(AWS CLI、SDKなど)でスクリプトを組んで、テストデータを作成する必要がありましたが、今回からAWS Amplifyで完結できるようになりました。
開発がより迅速になる
AWS Amplifyは、サービス構築の迅速さが求められるケースで使用されることが多いと思います。
Cognitoのデータ作成時はSeedのAPIを使用する必要がありますが、S3やDynamoDBについてはAmplifyの記法をそのまま使用できるため、学習コストを抑えることができます。
なので、Amplify内で完結することで、学習コストが下がり、より迅速な構築が可能になったと思います。
Snadbox環境をより便利に使える
Sandbox環境は使用時に料金が発生するため、都度、作り直すことが多くなります。本機能を使えば、テストデータを効率的に作成でき、Sandbox環境を容易に再現できる点も便利だなと感じました。