Browse by section

QA 日本語

Playwright Code Patterns for Uploading Files to AWS S3

Automating E2E tests, I hit a case that needed files uploaded to S3. Normally the AWS CLI settles that, but running Playwright scenarios raises different needs: putting a file in S3 from inside the test code, or reproducing the flow where the frontend uploads directly to S3 via a presigned URL.

The short version is that the pattern depends on what you are testing.

Goal Pattern
Test setup and teardown 1. Direct SDK upload
Verify the real upload path of the service 2. Presigned URL
Verify the user’s actual interaction 3. Upload through the UI
Large files 7. Multipart upload

This article covers seven patterns I tried, plus the two traps that catch everyone with presigned URLs: Content-Type and CORS.

Sponsored

1. A simple upload with the SDK

Call the AWS SDK straight from the test code.

import { test } from '@playwright/test';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import fs from 'fs';

test('upload directly to 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',
  }));
});
Upside Downside
Trivial to implement Nothing like a real user action
Ideal for setup and teardown Requires S3 credentials directly

This fits “put a file in place before the test” and “clean up afterwards”. What it does not do is test the upload feature. It is scaffolding.

Do not skip the cleanup. Without a DeleteObjectCommand in test.afterAll, your test bucket fills with junk.

2. Using a presigned URL

This is what real services actually do. The backend issues a presigned URL and the client PUTs straight to S3.

In Playwright, the request fixture is more natural than adding node-fetch — it is the built-in API client, and the calls show up in the trace.

import { test, expect } from '@playwright/test';

test('upload to S3 with a presigned URL', 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' },
  });

  // status is a method on APIResponse, not a property
  expect(uploadRes.status()).toBe(200);
});

One gotcha. On Playwright’s APIResponse, status is a method. Writing uploadRes.status compares a function object and always fails. Coming from node-fetch, this catches people out.

Trap 1: the Content-Type must match what was signed

If the presigned URL was created with a ContentType, the PUT has to send the same value. Mismatch and you get SignatureDoesNotMatch.

// backend: ContentType included at signing time
const command = new PutObjectCommand({
  Bucket: 'my-bucket',
  Key: 'uploads/sample.txt',
  ContentType: 'text/plain',   // if you set this...
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 });

…then the client must send 'Content-Type': 'text/plain'. The reverse fails too: sending a Content-Type that was not part of the signature. Keep both sides aligned.

Trap 2: browsers need CORS configured on the bucket

The PUT works from test code in Node but fails from the browser — that is CORS. Without a CORS configuration on the bucket, the browser’s PUT dies at the preflight.

[
  {
    "AllowedOrigins": ["http://localhost:3000"],
    "AllowedMethods": ["PUT", "GET"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

Node-side tests are not subject to CORS at all. That is why you get “pattern 2 passes but pattern 3 fails”. Check this first when that happens.

Presigned URLs also expire (expiresIn). In CI, where a test can sit waiting, a short expiry can lapse between issuing and using the URL.

Sponsored

3. Uploading through the UI with Playwright

Set a file on <input type="file">, let the frontend pass it to the server, and the server store it in S3 — verified end to end.

If you can reach the input element, setInputFiles is the most stable route.

import { test, expect } from '@playwright/test';
import path from 'path';

test('upload to S3 through the UI', 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: 'Submit' }).click();
  await expect(page.locator('#upload-result')).toHaveText('Upload successful');
});

Use the filechooser event only when the input is hidden and unreachable.

const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Choose file' }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.resolve('tests/fixtures/sample.txt'));

Hold the waitForEvent promise in a variable rather than awaiting it before the click. Awaiting first blocks on the event before the click ever happens.

Upside Downside
Verifies production-equivalent behaviour Slow
Catches CORS and permission problems too Many possible causes when it fails

The part where the backend transfers to S3 is invisible from the screen. A success message is not enough, so confirm the object actually exists using the SDK from pattern 1.

import { HeadObjectCommand } from '@aws-sdk/client-s3';

// after the on-screen success message, verify the object itself
await s3.send(new HeadObjectCommand({
  Bucket: 'my-bucket',
  Key: 'uploads/sample.txt',
}));

How to decide what belongs in E2E in the first place is covered in what to protect with E2E tests.

4. Seeding test data into S3

Useful in CI/CD: put the images or data you need into S3 before the run, then execute scenarios that assume they exist.

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',
  }));
});

If you run in parallel, mix the worker index or a timestamp into the key. A fixed key means multiple workers fight over the same object, and the suite turns flaky.

const key = `test/${process.env.TEST_WORKER_INDEX}/avatar.png`;

Sponsored

5. Managing SDK credentials with .env

Never hard-code credentials. Environment variables through dotenv are the simplest safe option.

.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();   // load it in the config, not in each test file

export default defineConfig({ /* ... */ });

Put dotenv.config() in playwright.config.ts, not in individual tests. The config file is read once before the run, so every test sees the variables.

And add .env to .gitignore. Forget that and the keys leak the moment the repository goes public.

.env
.env.local

6. Handling credentials safely in CI

.env is fine locally. In CI there are three options.

Approach Assessment
Store keys in GitHub Actions Secrets Easy, but long-lived keys persist
Attach an IAM role to EC2 or Lambda Works for self-hosted runners
Assume a role via OIDC Recommended. No keys in Secrets at all

OIDC is the choice today. GitHub Actions issues an ID token, AWS grants a temporary role, and no long-lived access key is stored anywhere.

permissions:
  id-token: write     # without this, no OIDC token is issued
  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

Forgetting permissions: id-token: write is the classic failure — no token is issued and authentication fails.

On the AWS side, register GitHub as an identity provider and restrict the trust policy to your repository and branch. Without that restriction, other people’s repositories can assume the role.

7. Multipart upload for large files

For video or multi-gigabyte data, use Upload from @aws-sdk/lib-storage. A plain PutObjectCommand times out easily on large files.

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,                 // concurrency (default 4)
  partSize: 1024 * 1024 * 5,    // part size (default and minimum 5 MB)
  leavePartsOnError: false,     // default false: aborts automatically on failure
});

upload.on('httpUploadProgress', (p) => {
  console.log(`${p.loaded} / ${p.total}`);
});

await upload.done();
Option Default Meaning
queueSize 4 Parts uploaded concurrently
partSize 5 MB Size of each part. 5 MB minimum
leavePartsOnError false true skips the automatic abort on failure

Setting leavePartsOnError: true makes you responsible for cleaning up half-finished parts. Incomplete multipart uploads continue to incur S3 storage charges. A lifecycle rule that deletes incomplete multipart uploads is the reliable fix.

Because Body accepts a stream, data of unknown size streams straight through. Knowing @aws-sdk/lib-storage exists changes how hard this area is.

Quick troubleshooting table

Symptom Cause
SignatureDoesNotMatch Content-Type differs between signing and PUT
Works in Node, fails in the browser No CORS configuration on the bucket
expect(res.status).toBe(200) always fails On Playwright’s APIResponse, status() is a method
OIDC authentication fails in CI Missing permissions: id-token: write
Flaky under parallel execution Test data uses a fixed key and collides
Presigned URL expired expiresIn set too short
S3 bill creeping up Incomplete multipart uploads left behind

Summary

  • Direct SDK upload: for setup and teardown. It does not verify the feature
  • Presigned URL: closest to production. Content-Type matching and CORS are the two traps
  • UI upload: setInputFiles by default; filechooser only for hidden inputs
  • Back up UI results by confirming the object with HeadObjectCommand
  • Mix the worker index into keys when running in parallel
  • Put dotenv.config() in playwright.config.ts, and always gitignore .env
  • In CI, prefer OIDC, and do not forget permissions: id-token: write
  • Large files use Upload from @aws-sdk/lib-storage. 5 MB minimum part size, default concurrency 4
  • Incomplete multipart uploads cost money. Delete them with a lifecycle rule

Wiring S3 into Playwright tests turned out to be interesting for one reason: you pick up AWS infrastructure knowledge as a side effect of writing E2E tests. Pick whichever pattern fits your situation.