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:
setInputFilesby 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()inplaywright.config.ts, and always gitignore.env - In CI, prefer OIDC, and do not forget
permissions: id-token: write - Large files use
Uploadfrom@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.