E2Eテストの自動化で「ファイルをS3にアップロードする必要がある」場面に遭遇しました。普通なら AWS CLI で済む話ですが、Playwrightでシナリオを回していると「テストコードの中でS3にファイルを置きたい」「フロントが署名付きURLでS3に直アップロードする流れを再現したい」というニーズが出てきます。
結論から書くと、用途によって使い分けが必要です。
| やりたいこと | 選ぶパターン |
|---|---|
| テストの前準備・後片付け | 1. SDKで直接アップロード |
| 実サービスのアップロード経路を検証 | 2. 署名付きURL |
| ユーザー操作そのものを検証 | 3. UIからアップロード |
| 大容量ファイル | 7. マルチパートアップロード |
この記事では実際に試した7パターンに加えて、署名付きURLで必ずハマる2つの落とし穴(CORSとContent-Type)も書きます。
スポンサーリンク
1. SDKを使ってシンプルにアップロードする
AWS SDKをそのままテストコードの中で呼んでしまうパターンです。
import { test } from '@playwright/test';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import fs from 'fs';
test('S3へ直接アップロード', async () => {
const s3 = new S3Client({
region: 'ap-northeast-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
const fileContent = fs.readFileSync('tests/fixtures/sample.txt');
await s3.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'uploads/sample.txt',
Body: fileContent,
ContentType: 'text/plain',
}));
});
| メリット | デメリット |
|---|---|
| 実装が簡単 | 実ユーザー操作とは乖離している |
| テスト準備や後片付けに便利 | S3の権限キーを直に使う必要がある |
「テスト開始前にファイルを置いておきたい」「テスト完了後に掃除したい」という用途に相性がいいパターンです。逆に、これで「アップロード機能をテストした」ことにはなりません。あくまで裏方です。
後片付けを忘れないでください。 test.afterAll で DeleteObjectCommand を呼ばないと、テスト用バケットがゴミで埋まります。
2. 署名付きURL(Presigned URL)を使う
実際のサービスがやっている流れそのものです。 バックエンドで署名付きURLを発行して、PUTリクエストでS3に直アップロードします。
Playwrightで書くなら、node-fetchを追加するより request フィクスチャを使うほうが素直です。 Playwright標準のAPIクライアントで、レポートにリクエストが残ります。
import { test, expect } from '@playwright/test';
test('署名付きURLでS3にアップロード', async ({ request }) => {
const apiRes = await request.get('http://localhost:3000/api/presigned-url');
const { url } = await apiRes.json();
const uploadRes = await request.put(url, {
data: Buffer.from('Hello from Playwright!'),
headers: { 'Content-Type': 'text/plain' },
});
// APIResponse の status はメソッド呼び出し(プロパティではない)
expect(uploadRes.status()).toBe(200);
});
注意点が1つ。 Playwrightの APIResponse では status はメソッドです。uploadRes.status と書くと関数オブジェクトと比較することになり、必ず失敗します。node-fetchのつもりで書くとここで詰まります。
落とし穴1:Content-Type が署名時と一致していないと失敗する
署名付きURLを発行するときに ContentType を指定したなら、PUTするときも同じ値を送る必要があります。 一致しないと SignatureDoesNotMatch になります。
// バックエンド側:署名時にContentTypeを含めた場合
const command = new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'uploads/sample.txt',
ContentType: 'text/plain', // ← これを指定したなら
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 });
この場合、クライアント側も必ず 'Content-Type': 'text/plain' を送ります。署名時に指定していないのにクライアントが送る、という逆パターンでも失敗します。 両側を揃えてください。
落とし穴2:ブラウザから使うならS3のCORS設定が要る
テストコード(Node側)からのPUTでは通るのに、ブラウザからだと失敗する——これはCORSです。バケットにCORS設定が入っていないと、ブラウザのPUTはプリフライトで止まります。
[
{
"AllowedOrigins": ["http://localhost:3000"],
"AllowedMethods": ["PUT", "GET"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
Node側のテストはCORSの影響を受けません。 だから「パターン2は通るのにパターン3が落ちる」という状況が起きます。切り分けの第一候補だと覚えておくと早いです。
署名付きURLには有効期限(expiresIn)もあります。CIで待ち時間が長いテストだと、発行から使用までの間に切れることがあるので、短すぎる値は避けてください。
スポンサーリンク
3. PlaywrightでUIからファイルアップロード
<input type="file"> にファイルをセット → フロント経由でサーバーに渡し → S3に保存、という流れをそのままE2Eで検証します。
input要素に直接セットできるなら、setInputFiles が最も安定します。
import { test, expect } from '@playwright/test';
import path from 'path';
test('UIからS3へアップロード', async ({ page }) => {
await page.goto('http://localhost:3000/upload');
await page.locator('input[type="file"]')
.setInputFiles(path.resolve('tests/fixtures/sample.txt'));
await page.getByRole('button', { name: '送信' }).click();
await expect(page.locator('#upload-result')).toHaveText('Upload successful');
});
input要素が隠されていて触れない場合だけ、filechooserイベントを使います。
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'ファイルを選択' }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.resolve('tests/fixtures/sample.txt'));
waitForEvent は必ずクリックより先に await せずに変数へ入れてください。 先に await してしまうと、クリック前にイベント待ちで止まります。上のように Promise を保持してからクリックするのが定石です。
| メリット | デメリット |
|---|---|
| 本番同様の動作確認ができる | テストが遅くなる |
| CORSや権限の問題も一緒に検出できる | 失敗時の原因の候補が多い |
「バックエンドがS3に転送する部分」は画面から見えません。 アップロード成功のメッセージだけでは不十分なので、パターン1のSDK呼び出しで実際にオブジェクトが存在するかを確認するのが確実です。
import { HeadObjectCommand } from '@aws-sdk/client-s3';
// 画面上の成功表示のあとに、実体を確認する
await s3.send(new HeadObjectCommand({
Bucket: 'my-bucket',
Key: 'uploads/sample.txt',
}));
何をE2Eの対象に選ぶかという判断軸はE2Eで「守るべきもの」はこう決めるにまとめています。
4. テストデータをS3に事前配置する
CI/CDで便利なパターンです。テスト前にS3へ必要な画像やデータを置いてから、その存在を前提にシナリオを走らせます。
test.beforeAll(async () => {
const s3 = new S3Client({ region: 'ap-northeast-1' });
const fileContent = fs.readFileSync('tests/fixtures/avatar.png');
await s3.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'test/avatar.png',
Body: fileContent,
ContentType: 'image/png',
}));
});
test.afterAll(async () => {
await s3.send(new DeleteObjectCommand({
Bucket: 'my-bucket',
Key: 'test/avatar.png',
}));
});
並列実行するなら、キーにワーカー番号やタイムスタンプを混ぜてください。 固定キーだと、複数ワーカーが同じオブジェクトを取り合って不安定になります。
const key = `test/${process.env.TEST_WORKER_INDEX}/avatar.png`;
スポンサーリンク
5. AWS SDKの認証を.envで管理する
認証情報をコードに直接書かないでください。 dotenvで環境変数にするのが一番ラクで安全です。
.env
AWS_ACCESS_KEY_ID=xxxxxxxx
AWS_SECRET_ACCESS_KEY=yyyyyyyy
AWS_REGION=ap-northeast-1
playwright.config.ts
import { defineConfig } from '@playwright/test';
import dotenv from 'dotenv';
dotenv.config(); // テストファイルではなく設定ファイルで読み込む
export default defineConfig({ /* ... */ });
dotenv.config() は各テストファイルではなく playwright.config.ts に置きます。 設定ファイルはテスト実行前に一度だけ読み込まれるので、どのテストからも環境変数が見える状態になります。
そして.gitignore に .env を必ず入れてください。 ここを忘れると、リポジトリを公開した瞬間にキーが漏れます。
.env
.env.local
6. CI環境での安全なキー設定
ローカルは.envでいいとして、CIではどうするか。選択肢は3つです。
| 方法 | 評価 |
|---|---|
| GitHub ActionsのSecretsにキーを登録 | 手軽。ただし長期キーが残る |
| IAM RoleをEC2やLambdaに付与 | 自前ランナーなら有効 |
| OIDCでロールを引き受ける | 推奨。Secretsにキーを置かなくて済む |
いま選ぶならOIDCです。 GitHub Actions が発行するIDトークンでAWSのロールを一時的に引き受けるので、長期の access key をどこにも保存しません。
permissions:
id-token: write # これが無いとOIDCトークンを取得できない
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-s3
aws-region: ap-northeast-1
permissions: id-token: write の書き忘れが定番のつまずきポイントです。これが無いとトークンが取得できず、認証に失敗します。
AWS側にはGitHubをIDプロバイダとして登録し、信頼ポリシーでリポジトリとブランチを限定してください。限定しないと、他人のリポジトリからもロールを引き受けられてしまいます。
7. 大容量ファイルのマルチパートアップロード
動画や数GBのデータを扱うときは、@aws-sdk/lib-storage の Upload を使います。単純な PutObjectCommand では、大きいファイルでタイムアウトしやすくなります。
import { Upload } from '@aws-sdk/lib-storage';
import { S3Client } from '@aws-sdk/client-s3';
import fs from 'fs';
const s3 = new S3Client({ region: 'ap-northeast-1' });
const upload = new Upload({
client: s3,
params: {
Bucket: 'my-bucket',
Key: 'bigfile.zip',
Body: fs.createReadStream('bigfile.zip'),
},
queueSize: 4, // 並列数(既定値4)
partSize: 1024 * 1024 * 5, // 1パートのサイズ(既定値5MB・最小5MB)
leavePartsOnError: false, // 既定値false。失敗時に自動でAbortする
});
upload.on('httpUploadProgress', (p) => {
console.log(`${p.loaded} / ${p.total}`);
});
await upload.done();
| オプション | 既定値 | 意味 |
|---|---|---|
queueSize |
4 | 同時にアップロードするパート数 |
partSize |
5MB | 1パートのサイズ。最小5MB |
leavePartsOnError |
false | trueにすると失敗時に中断処理をしない |
leavePartsOnError: true にするなら、失敗した中途半端なパートを自分で消す責任が生じます。 未完了のマルチパートアップロードはS3の課金対象として残り続けます。バケットに「不完全なマルチパートアップロードを削除する」ライフサイクルルールを入れておくのが確実です。
Body にストリームを渡せるので、サイズが分からないデータもそのまま流せます。@aws-sdk/lib-storage を知っているかどうかで、この領域の難易度がかなり変わります。
ハマりどころ早見表
| 症状 | 原因 |
|---|---|
SignatureDoesNotMatch |
署名時とPUT時の Content-Type が不一致 |
| Node側は通るがブラウザだけ失敗 | S3のCORS設定が無い |
expect(res.status).toBe(200) が必ず失敗 |
PlaywrightのAPIResponseでは status() はメソッド |
| CIで認証に失敗(OIDC) | permissions: id-token: write の書き忘れ |
| 並列実行で不安定 | テストデータのキーが固定で衝突している |
| 署名付きURLが期限切れ | expiresIn が短すぎる |
| S3の請求が増えていく | 未完了のマルチパートが残っている |
まとめ
- SDKで直接アップロード:テストの前準備と後片付け向き。機能検証にはならない
- 署名付きURL:実サービスに近い。Content-Typeの一致とCORSが2大ハマりどころ
- UIからアップロード:
setInputFilesが基本。隠れたinputのときだけfilechooser - UIテストの成否は
HeadObjectCommandで実体を確認して裏取りする - 並列実行するならキーにワーカー番号を混ぜる
dotenv.config()はplaywright.config.tsに置く。.envは必ず.gitignore- CIはOIDC推奨。
permissions: id-token: writeを忘れない - 大容量は
@aws-sdk/lib-storageのUpload。partSize最小5MB、既定の並列数は4 - 未完了マルチパートは課金対象。ライフサイクルルールで自動削除する
PlaywrightのテストにS3を絡めてみて、「ただのE2Eテストなのに、AWSインフラの知識が自然とついてくる」のが面白かったです。自分の現場に合うパターンを選んでみてください。