File Upload Testing Fundamentals
Testing file uploads is essential for applications that handle user-generated content like profile pictures, documents, videos, or attachments. Proper file upload testing verifies that files are validated, stored correctly, retrieved accurately, and deleted when appropriate—all without filling your test environment with real files.
Key Principle: Never use real file I/O in tests. Use fake storage systems that simulate file operations in memory. This keeps tests fast, isolated, and prevents cluttering your filesystem with test files. Laravel's Storage::fake() and similar tools make this simple.
Why Test File Uploads?
File upload functionality has unique security and validation concerns:
- Security: Prevent malicious file uploads (executables, scripts disguised as images)
- Validation: Ensure only allowed file types and sizes are accepted
- Storage: Verify files are saved to correct locations with proper permissions
- Retrieval: Confirm files can be downloaded or displayed correctly
- Cleanup: Test that deleting records also deletes associated files
- Error Handling: Validate behavior when uploads fail or storage is full
Laravel Storage Testing
Laravel's Storage facade provides a powerful fake disk for testing:
Basic Fake Storage Setup
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class FileUploadTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_upload_avatar()
{
Storage::fake('public'); // Create fake disk
$user = User::factory()->create();
$file = UploadedFile::fake()->image('avatar.jpg', 200, 200);
$response = $this->actingAs($user)
->post('/profile/avatar', [
'avatar' => $file
]);
$response->assertOk();
// Assert file was stored
Storage::disk('public')->assertExists('avatars/' . $file->hashName());
// Assert database was updated
$this->assertDatabaseHas('users', [
'id' => $user->id,
'avatar' => 'avatars/' . $file->hashName()
]);
}
}
Storage::fake() Benefits:
- Creates an in-memory filesystem—no real files written to disk
- Automatically cleaned up after each test
- Provides assertions:
assertExists(), assertMissing()
- Works with all Laravel storage operations (put, get, delete, copy, move)
- Can fake specific disks:
Storage::fake('s3')
Creating Fake Files
<?php
// Fake image with specific dimensions
$image = UploadedFile::fake()->image('photo.jpg', 1920, 1080);
// Fake image with size in kilobytes
$largeImage = UploadedFile::fake()->image('large.jpg')->size(5000); // 5MB
// Fake document
$pdf = UploadedFile::fake()->create('document.pdf', 1000); // 1MB PDF
// Fake with specific MIME type
$csv = UploadedFile::fake()->create('data.csv', 500, 'text/csv');
// Multiple files
$files = [
UploadedFile::fake()->image('photo1.jpg'),
UploadedFile::fake()->image('photo2.jpg'),
UploadedFile::fake()->image('photo3.jpg'),
];
File Validation Testing
Test that your application properly validates uploaded files:
File Type Validation
<?php
public function test_avatar_must_be_image()
{
Storage::fake('public');
$user = User::factory()->create();
$file = UploadedFile::fake()->create('document.pdf', 1000);
$response = $this->actingAs($user)
->post('/profile/avatar', [
'avatar' => $file
]);
$response->assertSessionHasErrors('avatar');
Storage::disk('public')->assertMissing('avatars/' . $file->hashName());
}
public function test_only_specific_image_types_allowed()
{
Storage::fake('public');
$user = User::factory()->create();
// Allowed: JPEG, PNG, GIF
$jpeg = UploadedFile::fake()->image('photo.jpg');
$png = UploadedFile::fake()->image('photo.png');
$gif = UploadedFile::fake()->image('photo.gif');
// Not allowed: BMP
$bmp = UploadedFile::fake()->create('photo.bmp', 100, 'image/bmp');
$this->actingAs($user)
->post('/profile/avatar', ['avatar' => $jpeg])
->assertOk();
$this->actingAs($user)
->post('/profile/avatar', ['avatar' => $bmp])
->assertSessionHasErrors('avatar');
}
File Size Validation
<?php
public function test_avatar_cannot_exceed_max_size()
{
Storage::fake('public');
$user = User::factory()->create();
// 3MB file (over 2MB limit)
$largeFile = UploadedFile::fake()->image('large.jpg')->size(3000);
$response = $this->actingAs($user)
->post('/profile/avatar', [
'avatar' => $largeFile
]);
$response->assertSessionHasErrors('avatar');
}
public function test_avatar_within_size_limit_accepted()
{
Storage::fake('public');
$user = User::factory()->create();
// 1.5MB file (within 2MB limit)
$validFile = UploadedFile::fake()->image('valid.jpg')->size(1500);
$response = $this->actingAs($user)
->post('/profile/avatar', [
'avatar' => $validFile
]);
$response->assertOk();
Storage::disk('public')->assertExists('avatars/' . $validFile->hashName());
}
Dimension Validation for Images
<?php
public function test_avatar_must_meet_minimum_dimensions()
{
Storage::fake('public');
$user = User::factory()->create();
// Too small (require minimum 200x200)
$tooSmall = UploadedFile::fake()->image('small.jpg', 100, 100);
$response = $this->actingAs($user)
->post('/profile/avatar', [
'avatar' => $tooSmall
]);
$response->assertSessionHasErrors('avatar');
}
public function test_banner_must_have_correct_aspect_ratio()
{
Storage::fake('public');
$user = User::factory()->create();
// Wrong aspect ratio (require 16:9)
$wrongRatio = UploadedFile::fake()->image('banner.jpg', 1000, 1000); // 1:1
$response = $this->actingAs($user)
->post('/profile/banner', [
'banner' => $wrongRatio
]);
$response->assertSessionHasErrors('banner');
// Correct aspect ratio
$correctRatio = UploadedFile::fake()->image('banner.jpg', 1920, 1080); // 16:9
$response = $this->actingAs($user)
->post('/profile/banner', [
'banner' => $correctRatio
]);
$response->assertOk();
}
Testing File Storage and Retrieval
Testing Storage Paths
<?php
public function test_files_stored_in_correct_directory()
{
Storage::fake('s3');
$post = Post::factory()->create();
$image = UploadedFile::fake()->image('cover.jpg');
$response = $this->actingAs($post->user)
->post("/posts/{$post->id}/cover", [
'cover' => $image
]);
// Assert file stored in correct path
Storage::disk('s3')->assertExists(
'posts/' . $post->id . '/' . $image->hashName()
);
}
public function test_files_organized_by_date()
{
Storage::fake('public');
$user = User::factory()->create();
$document = UploadedFile::fake()->create('report.pdf', 1000);
$this->actingAs($user)
->post('/documents', [
'file' => $document
]);
$datePath = now()->format('Y/m/d');
Storage::disk('public')->assertExists(
"documents/{$datePath}/" . $document->hashName()
);
}
Testing File Retrieval
<?php
public function test_user_can_download_own_document()
{
Storage::fake('private');
$user = User::factory()->create();
$content = 'Test document content';
Storage::disk('private')->put(
'documents/test.pdf',
$content
);
$document = Document::create([
'user_id' => $user->id,
'filename' => 'test.pdf',
'path' => 'documents/test.pdf'
]);
$response = $this->actingAs($user)
->get("/documents/{$document->id}/download");
$response->assertOk();
$response->assertHeader('Content-Type', 'application/pdf');
$this->assertEquals($content, $response->getContent());
}
public function test_user_cannot_download_other_users_document()
{
Storage::fake('private');
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$document = Document::factory()->for($owner)->create();
$response = $this->actingAs($otherUser)
->get("/documents/{$document->id}/download");
$response->assertForbidden();
}
Testing File Deletion
<?php
public function test_deleting_post_deletes_associated_images()
{
Storage::fake('public');
$post = Post::factory()->create();
$image = UploadedFile::fake()->image('cover.jpg');
// Upload image
$this->actingAs($post->user)
->post("/posts/{$post->id}/cover", [
'cover' => $image
]);
$imagePath = $post->fresh()->cover_image;
Storage::disk('public')->assertExists($imagePath);
// Delete post
$this->actingAs($post->user)
->delete("/posts/{$post->id}");
// Assert image was deleted
Storage::disk('public')->assertMissing($imagePath);
}
public function test_replacing_avatar_deletes_old_file()
{
Storage::fake('public');
$user = User::factory()->create();
// Upload first avatar
$oldAvatar = UploadedFile::fake()->image('old.jpg');
$this->actingAs($user)
->post('/profile/avatar', ['avatar' => $oldAvatar]);
$oldPath = $user->fresh()->avatar;
Storage::disk('public')->assertExists($oldPath);
// Upload new avatar
$newAvatar = UploadedFile::fake()->image('new.jpg');
$this->actingAs($user)
->post('/profile/avatar', ['avatar' => $newAvatar]);
$newPath = $user->fresh()->avatar;
// Assert old file deleted, new file exists
Storage::disk('public')->assertMissing($oldPath);
Storage::disk('public')->assertExists($newPath);
}
Testing Multiple File Uploads
<?php
public function test_can_upload_multiple_images()
{
Storage::fake('public');
$post = Post::factory()->create();
$images = [
UploadedFile::fake()->image('photo1.jpg'),
UploadedFile::fake()->image('photo2.jpg'),
UploadedFile::fake()->image('photo3.jpg'),
];
$response = $this->actingAs($post->user)
->post("/posts/{$post->id}/gallery", [
'images' => $images
]);
$response->assertOk();
// Assert all images stored
foreach ($images as $image) {
Storage::disk('public')->assertExists(
'gallery/' . $image->hashName()
);
}
// Assert database records created
$this->assertDatabaseCount('post_images', 3);
}
public function test_validates_all_files_in_batch_upload()
{
Storage::fake('public');
$post = Post::factory()->create();
$files = [
UploadedFile::fake()->image('photo1.jpg'),
UploadedFile::fake()->create('virus.exe', 100), // Invalid
UploadedFile::fake()->image('photo3.jpg'),
];
$response = $this->actingAs($post->user)
->post("/posts/{$post->id}/gallery", [
'images' => $files
]);
$response->assertSessionHasErrors('images.1');
// Assert no files stored due to validation failure
Storage::disk('public')->assertMissing('gallery/' . $files[0]->hashName());
Storage::disk('public')->assertMissing('gallery/' . $files[2]->hashName());
}
Testing File Storage with Different Disks
<?php
public function test_public_files_stored_on_public_disk()
{
Storage::fake('public');
$post = Post::factory()->create();
$cover = UploadedFile::fake()->image('cover.jpg');
$this->actingAs($post->user)
->post("/posts/{$post->id}/cover", [
'cover' => $cover
]);
Storage::disk('public')->assertExists(
'covers/' . $cover->hashName()
);
}
public function test_private_documents_stored_on_private_disk()
{
Storage::fake('private');
$user = User::factory()->create();
$document = UploadedFile::fake()->create('confidential.pdf', 1000);
$this->actingAs($user)
->post('/documents', [
'file' => $document
]);
Storage::disk('private')->assertExists(
'documents/' . $document->hashName()
);
}
public function test_large_files_stored_on_s3()
{
Storage::fake('s3');
$user = User::factory()->create();
$video = UploadedFile::fake()->create('video.mp4', 50000); // 50MB
$this->actingAs($user)
->post('/videos', [
'video' => $video
]);
Storage::disk('s3')->assertExists(
'videos/' . $video->hashName()
);
}
JavaScript File Upload Testing
Test file uploads in JavaScript applications:
// tests/components/FileUpload.test.jsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { FileUploadForm } from '../FileUploadForm';
import { uploadFile } from '../../services/api';
jest.mock('../../services/api');
describe('FileUploadForm', () => {
test('uploads file successfully', async () => {
uploadFile.mockResolvedValue({
success: true,
filename: 'avatar.jpg',
url: '/storage/avatars/avatar.jpg'
});
render(<FileUploadForm />);
const file = new File(['avatar'], 'avatar.jpg', { type: 'image/jpeg' });
const input = screen.getByLabelText('Upload Avatar');
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
expect(uploadFile).toHaveBeenCalledWith(file);
expect(screen.getByText('Upload successful')).toBeInTheDocument();
});
});
test('shows error for invalid file type', async () => {
render(<FileUploadForm />);
const file = new File(['document'], 'document.pdf', { type: 'application/pdf' });
const input = screen.getByLabelText('Upload Avatar');
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText('Only images allowed')).toBeInTheDocument();
});
});
test('shows error for file too large', async () => {
render(<FileUploadForm maxSize={2 * 1024 * 1024} />); // 2MB
// Create 3MB file
const largeFile = new File(
[new ArrayBuffer(3 * 1024 * 1024)],
'large.jpg',
{ type: 'image/jpeg' }
);
const input = screen.getByLabelText('Upload Avatar');
fireEvent.change(input, { target: { files: [largeFile] } });
await waitFor(() => {
expect(screen.getByText('File size exceeds 2MB')).toBeInTheDocument();
});
});
test('displays upload progress', async () => {
uploadFile.mockImplementation(() => {
return new Promise((resolve) => {
setTimeout(() => resolve({ success: true }), 1000);
});
});
render(<FileUploadForm />);
const file = new File(['avatar'], 'avatar.jpg', { type: 'image/jpeg' });
const input = screen.getByLabelText('Upload Avatar');
fireEvent.change(input, { target: { files: [file] } });
expect(screen.getByText('Uploading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Upload successful')).toBeInTheDocument();
});
});
});
Best Practices for File Upload Testing
1. Always Use Fake Storage
Never write real files during tests. Use Storage::fake() in Laravel or mock file systems in JavaScript.
2. Test All Validation Rules
Test file type, size, dimensions, and any custom validation. Include both valid and invalid cases.
3. Test File Cleanup
Verify that deleting records also deletes associated files to prevent orphaned files.
4. Test Authorization
Ensure users can only upload/download/delete their own files or files they have permission to access.
5. Test Error Handling
Simulate upload failures, storage full scenarios, and network errors.
6. Keep Test Files Small
Use small fake files (1-10KB) for speed. Only use larger files when testing size limits.
Security Testing Checklist:
- File Type Validation: Block executable files (.exe, .sh, .php)
- MIME Type Verification: Don't trust client-provided MIME types
- Filename Sanitization: Prevent path traversal (../../etc/passwd)
- Size Limits: Prevent DoS attacks via huge uploads
- Virus Scanning: For production, integrate antivirus scanning
- Public Access: Verify private files aren't publicly accessible
Practical Exercise
Exercise 1: Test a document management system
<?php
// Features to test:
// 1. User can upload PDF documents (max 10MB)
// 2. Documents organized by user_id/year/month/
// 3. User can download their own documents
// 4. User cannot download other users' documents
// 5. Admin can download any document
// 6. Deleting a document deletes the file
// 7. Only PDF, DOC, DOCX allowed
// 8. Filename stored in database with metadata
// Write comprehensive test suite
Exercise 2: Test a multi-image product gallery
// Features to test:
// 1. Upload up to 5 images per product
// 2. First image is primary, others are gallery
// 3. Images must be JPEG/PNG, min 800x600px
// 4. Reordering images updates display order
// 5. Deleting product deletes all images
// 6. Replacing primary image deletes old one
// 7. Generate thumbnails (test stored separately)
// Write tests with Storage::fake()
Exercise 3: Test avatar upload with image processing
// Features to test:
// 1. Upload avatar (JPEG/PNG, max 2MB)
// 2. Resize to 200x200px (test dimensions)
// 3. Generate 50x50px thumbnail
// 4. Store original and thumbnail
// 5. Old avatars deleted on replacement
// 6. Default avatar used if none uploaded
// 7. Avatar URL returned in API response
// Hint: Use Intervention Image or similar
// Mock image processing in tests