Testing File Upload and Download in Node.js: Jest, Supertest, and S3 Mocks
File upload bugs are painful: silent failures, incorrect MIME type handling, size limit bypasses, and S3 permissions that work in dev but fail in production. This guide covers how to test multipart uploads, presigned URL flows, and file download with Jest and Supertest — without hitting real S3.
Setup
npm install multer @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
npm install -D jest supertest @types/jest @types/supertest aws-sdk-client-mockTesting Multipart Upload Endpoints
// routes/uploadRouter.ts
import express from 'express'
import multer from 'multer'
import { uploadToS3 } from '../services/s3Service'
const upload = multer({
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
fileFilter: (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
if (allowed.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error(`File type ${file.mimetype} not allowed`))
}
},
})
const router = express.Router()
router.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file provided' })
}
const result = await uploadToS3({
buffer: req.file.buffer,
contentType: req.file.mimetype,
originalName: req.file.originalname,
})
res.json({ url: result.url, key: result.key })
})
export default router// routes/uploadRouter.test.ts
import supertest from 'supertest'
import path from 'path'
import fs from 'fs'
import app from '../app'
import { uploadToS3 } from '../services/s3Service'
jest.mock('../services/s3Service')
const mockUploadToS3 = uploadToS3 as jest.Mock
describe('POST /upload', () => {
beforeEach(() => {
mockUploadToS3.mockResolvedValue({
url: 'https://bucket.s3.amazonaws.com/uploads/test.jpg',
key: 'uploads/test.jpg',
})
})
afterEach(() => jest.clearAllMocks())
it('uploads a JPEG file successfully', async () => {
const response = await supertest(app)
.post('/upload')
.set('Authorization', `Bearer ${testToken}`)
.attach('file', Buffer.from('fake-image-data'), {
filename: 'photo.jpg',
contentType: 'image/jpeg',
})
expect(response.status).toBe(200)
expect(response.body.url).toContain('s3.amazonaws.com')
expect(response.body.key).toBeDefined()
})
it('uploads a PNG file', async () => {
const response = await supertest(app)
.post('/upload')
.set('Authorization', `Bearer ${testToken}`)
.attach('file', Buffer.from('fake-png-data'), {
filename: 'image.png',
contentType: 'image/png',
})
expect(response.status).toBe(200)
})
it('rejects disallowed file types', async () => {
const response = await supertest(app)
.post('/upload')
.set('Authorization', `Bearer ${testToken}`)
.attach('file', Buffer.from('malicious content'), {
filename: 'script.js',
contentType: 'application/javascript',
})
expect(response.status).toBe(400)
expect(response.body.error).toMatch(/not allowed/i)
})
it('returns 400 when no file is attached', async () => {
const response = await supertest(app)
.post('/upload')
.set('Authorization', `Bearer ${testToken}`)
expect(response.status).toBe(400)
expect(response.body.error).toMatch(/no file/i)
})
it('rejects files exceeding the size limit', async () => {
const largeBuffer = Buffer.alloc(11 * 1024 * 1024) // 11 MB
const response = await supertest(app)
.post('/upload')
.set('Authorization', `Bearer ${testToken}`)
.attach('file', largeBuffer, {
filename: 'huge.jpg',
contentType: 'image/jpeg',
})
expect(response.status).toBe(413) // Payload Too Large
})
it('calls uploadToS3 with correct parameters', async () => {
const imageBuffer = Buffer.from('image-bytes')
await supertest(app)
.post('/upload')
.set('Authorization', `Bearer ${testToken}`)
.attach('file', imageBuffer, {
filename: 'test.jpg',
contentType: 'image/jpeg',
})
expect(mockUploadToS3).toHaveBeenCalledWith(
expect.objectContaining({
contentType: 'image/jpeg',
originalName: 'test.jpg',
})
)
})
})Mocking S3 with aws-sdk-client-mock
// services/s3Service.test.ts
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
import { mockClient } from 'aws-sdk-client-mock'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { uploadToS3, generateDownloadUrl } from './s3Service'
jest.mock('@aws-sdk/s3-request-presigner')
const s3Mock = mockClient(S3Client)
const mockGetSignedUrl = getSignedUrl as jest.Mock
describe('uploadToS3', () => {
beforeEach(() => {
s3Mock.reset()
})
it('uploads buffer to S3 with correct metadata', async () => {
s3Mock.on(PutObjectCommand).resolves({ ETag: '"abc123"' })
const result = await uploadToS3({
buffer: Buffer.from('test-data'),
contentType: 'image/jpeg',
originalName: 'photo.jpg',
})
expect(result.key).toMatch(/uploads\/.*\.jpg/)
expect(result.url).toContain(result.key)
const calls = s3Mock.commandCalls(PutObjectCommand)
expect(calls).toHaveLength(1)
expect(calls[0].args[0].input.ContentType).toBe('image/jpeg')
})
it('generates unique keys for each upload', async () => {
s3Mock.on(PutObjectCommand).resolves({})
const [result1, result2] = await Promise.all([
uploadToS3({ buffer: Buffer.from('a'), contentType: 'image/png', originalName: 'a.png' }),
uploadToS3({ buffer: Buffer.from('b'), contentType: 'image/png', originalName: 'b.png' }),
])
expect(result1.key).not.toBe(result2.key)
})
it('propagates S3 errors', async () => {
s3Mock.on(PutObjectCommand).rejects(new Error('Access Denied'))
await expect(
uploadToS3({ buffer: Buffer.from('x'), contentType: 'image/jpeg', originalName: 'x.jpg' })
).rejects.toThrow('Access Denied')
})
})
describe('generateDownloadUrl', () => {
it('generates a presigned URL for the given key', async () => {
mockGetSignedUrl.mockResolvedValue('https://bucket.s3.amazonaws.com/key?X-Amz-Signature=abc')
const url = await generateDownloadUrl('uploads/photo.jpg', 3600)
expect(url).toContain('X-Amz-Signature')
expect(mockGetSignedUrl).toHaveBeenCalledWith(
expect.any(S3Client),
expect.any(GetObjectCommand),
{ expiresIn: 3600 }
)
})
})Testing Presigned URL Flow E2E
// tests/api/presigned-upload.test.ts
describe('Presigned URL upload flow', () => {
it('client uploads directly to S3 using presigned URL', async () => {
// Step 1: Get presigned URL from API
const presignedResponse = await supertest(app)
.post('/api/uploads/presigned')
.set('Authorization', `Bearer ${testToken}`)
.send({ filename: 'avatar.jpg', contentType: 'image/jpeg' })
expect(presignedResponse.status).toBe(200)
expect(presignedResponse.body.uploadUrl).toBeDefined()
expect(presignedResponse.body.key).toBeDefined()
expect(new URL(presignedResponse.body.uploadUrl).searchParams.get('X-Amz-Signature')).toBeDefined()
// Step 2: Simulate client uploading to presigned URL
// (In real E2E, this would be a PUT to the presigned URL)
const { key } = presignedResponse.body
// Step 3: Confirm the upload
const confirmResponse = await supertest(app)
.post('/api/uploads/confirm')
.set('Authorization', `Bearer ${testToken}`)
.send({ key })
expect(confirmResponse.status).toBe(200)
expect(confirmResponse.body.fileUrl).toBeDefined()
})
})Testing File Download
describe('GET /files/:key', () => {
it('returns file content with correct content type', async () => {
// Mock S3 GetObject to return a buffer
s3Mock.on(GetObjectCommand).resolves({
Body: createReadableStream(Buffer.from('image-bytes')),
ContentType: 'image/jpeg',
ContentLength: 12,
})
const response = await supertest(app)
.get('/files/uploads/photo.jpg')
.set('Authorization', `Bearer ${testToken}`)
expect(response.status).toBe(200)
expect(response.headers['content-type']).toContain('image/jpeg')
})
it('returns 404 for non-existent files', async () => {
s3Mock.on(GetObjectCommand).rejects(
Object.assign(new Error('NoSuchKey'), { name: 'NoSuchKey' })
)
const response = await supertest(app)
.get('/files/uploads/nonexistent.jpg')
.set('Authorization', `Bearer ${testToken}`)
expect(response.status).toBe(404)
})
})Testing MIME Type Validation
Test that MIME type validation can't be bypassed by changing the filename:
describe('MIME type bypass attempts', () => {
it('rejects a PHP file disguised as a JPEG', async () => {
const response = await supertest(app)
.post('/upload')
.set('Authorization', `Bearer ${testToken}`)
.attach('file', Buffer.from('<?php echo shell_exec($_GET["cmd"]); ?>'), {
filename: 'image.jpg', // .jpg extension
contentType: 'image/jpeg', // claimed MIME type
})
// Your server should use magic bytes or re-validate, not trust the client's claim
// This test documents what SHOULD happen; adjust based on your implementation
expect([200, 400]).toContain(response.status)
// Log this if it returns 200 — it means your MIME validation relies on client input
})
})What Automated Tests Miss
Jest + Supertest tests cover upload logic but won't catch:
- S3 bucket policy changes that silently block uploads
- CDN caching that serves stale or wrong content-type headers
- Network timeouts during large file uploads on slow connections
- Browser file picker quirks — drag and drop vs. file input behavior
HelpMeTest runs scheduled E2E file upload tests in a real browser — clicking the upload button, attaching a real file, and verifying the result appears correctly on the page. Pro plan at $100/month.
Summary
Testing file uploads in Node.js:
- Supertest — attach files with
.attach(), test response status and body aws-sdk-client-mock— mock S3 commands without hitting real AWS- Test the error paths — rejected MIME types, size limits, S3 failures, missing files
- Test presigned URL flow — verify URL shape, key uniqueness, confirm endpoint
- Security — test MIME bypass attempts; don't trust client-supplied content types