Arcade Test
The arcade_test
package provides comprehensive testing utilities for Arcade applications, enabling easy testing of HTTP endpoints, WebSocket connections, and stateful components. It integrates seamlessly with Dart’s built-in test framework and offers a rich set of matchers for response validation.
Installation
Section titled “Installation”Add arcade_test
to your pubspec.yaml
:
dev_dependencies: arcade_test: ^<latest-version> test: ^1.24.0
Core Concepts
Section titled “Core Concepts”ArcadeTestServer
Section titled “ArcadeTestServer”The ArcadeTestServer
class provides lifecycle management for test servers with automatic port allocation:
abstract class ArcadeTestServer { // Create server with route configuration static Future<ArcadeTestServer> withRoutes(Function() routeSetup);
// HTTP client methods Future<TestResponse> get(String path); Future<TestResponse> post(String path, {Object? body}); Future<TestResponse> put(String path, {Object? body}); Future<TestResponse> patch(String path, {Object? body}); Future<TestResponse> delete(String path); Future<TestResponse> head(String path); Future<TestResponse> options(String path);
// WebSocket connection Future<TestWebSocket> webSocket(String path);
// Server management Future<void> close(); String get baseUrl;}
TestResponse
Section titled “TestResponse”Response wrapper with convenient parsing and validation methods:
class TestResponse { final int statusCode; final String body; final Map<String, List<String>> headers;
// Parse response body Map<String, dynamic> get jsonMap; List<dynamic> get jsonList;
// Content type checking bool get isJson; bool get isHtml; bool get isText;}
HTTP Status Matchers
Section titled “HTTP Status Matchers”Complete coverage of all Arcade-supported HTTP status codes:
// Success responsesMatcher isOk(); // 200Matcher isCreated(); // 201Matcher isNoContent(); // 204
// Client error responsesMatcher isBadRequest(); // 400Matcher isUnauthorized(); // 401Matcher isForbidden(); // 403Matcher isNotFound(); // 404Matcher isMethodNotAllowed(); // 405Matcher isConflict(); // 409Matcher isImATeapot(); // 418Matcher isUnprocessableEntity(); // 422
// Server error responsesMatcher isInternalServerError(); // 500Matcher isServiceUnavailable(); // 503
Quick Start
Section titled “Quick Start”Basic HTTP Testing
Section titled “Basic HTTP Testing”import 'package:arcade/arcade.dart';import 'package:arcade_test/arcade_test.dart';import 'package:test/test.dart';
void main() { group('API Tests', () { late ArcadeTestServer testServer;
setUpAll(() async { testServer = await ArcadeTestServer.withRoutes(() { route.get('/users').handle((context) { return [ {'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}, ]; });
route.post('/users').handle((context) async { final userData = await context.jsonMap(); return {'id': 3, ...userData}; });
route.get('/users/:id').handle((context) { final id = context.pathParameters['id']; return {'id': int.parse(id!), 'name': 'User $id'}; }); }); });
tearDownAll(() async { await testServer.close(); });
test('GET /users returns user list', () async { final response = await testServer.get('/users');
expect(response, isOk()); expect(response, isJson()); expect(response, hasJsonBody(isA<List>())); expect(response, hasJsonPath('[0].name', 'Alice')); });
test('POST /users creates new user', () async { final response = await testServer.post('/users', body: newUser);
expect(response, isOk()); expect(response, containsJsonKey('id')); expect(response, hasJsonPath('name', 'Charlie')); });
test('GET /users/:id returns specific user', () async { final response = await testServer.get('/users/42');
expect(response, isOk()); expect(response, hasJsonBody({'id': 42, 'name': 'User 42'})); }); });}
Response Body Validation
Section titled “Response Body Validation”test('response body matchers', () async { final server = await ArcadeTestServer.withRoutes(() { route.get('/json').handle((ctx) => {'message': 'Hello', 'count': 42}); route.get('/text').handle((ctx) => 'Plain text response'); route.get('/empty').handle((ctx) => ''); });
// JSON response testing final jsonResponse = await server.get('/json'); expect(jsonResponse, hasJsonBody({'message': 'Hello', 'count': 42})); expect(jsonResponse, containsJsonKey('message')); expect(jsonResponse, hasJsonPath('message', 'Hello')); expect(jsonResponse, hasJsonPath('count', greaterThan(40)));
// Text response testing final textResponse = await server.get('/text'); expect(textResponse, hasTextBody('Plain text response')); expect(textResponse, hasTextBody(contains('Plain')));
// Empty response testing final emptyResponse = await server.get('/empty'); expect(emptyResponse, hasEmptyBody());
await server.close();});
Header and Content Type Testing
Section titled “Header and Content Type Testing”test('header validation', () async { final server = await ArcadeTestServer.withRoutes(() { route.get('/api/data').handle((ctx) { ctx.responseHeaders.set('x-custom-header', 'custom-value'); ctx.responseHeaders.contentType = ContentType.json; return {'data': 'response'}; }); });
final response = await server.get('/api/data');
expect(response, hasHeader('x-custom-header')); expect(response, hasHeader('x-custom-header', 'custom-value')); expect(response, hasContentType('application/json')); expect(response, isJson());
await server.close();});
Error Response Testing
Section titled “Error Response Testing”test('error handling', () async { final server = await ArcadeTestServer.withRoutes(() { route.get('/not-found').handle((ctx) { throw ArcadeHttpException.notFound('Resource not found'); });
route.post('/validation-error').handle((ctx) { throw ArcadeHttpException.unprocessableEntity('Invalid data'); });
route.get('/server-error').handle((ctx) { throw ArcadeHttpException.internalServerError('Something went wrong'); }); });
// Test 404 errors final notFoundResponse = await server.get('/not-found'); expect(notFoundResponse, isNotFound()); expect(notFoundResponse, hasTextBody('Resource not found'));
// Test validation errors final validationResponse = await server.post('/validation-error'); expect(validationResponse, isUnprocessableEntity());
// Test server errors final serverErrorResponse = await server.get('/server-error'); expect(serverErrorResponse, isInternalServerError());
await server.close();});
WebSocket Testing
Section titled “WebSocket Testing”Basic WebSocket Testing
Section titled “Basic WebSocket Testing”test('WebSocket communication', () async { final server = await ArcadeTestServer.withRoutes(() { route.webSocket('/ws').handle((webSocket) { webSocket.listen((message) { if (message == 'ping') { webSocket.add('pong'); } else { webSocket.add('echo: $message'); } }); }); });
final ws = await server.webSocket('/ws');
// Send and receive messages ws.add('ping'); final pongMessage = await ws.stream.first; expect(pongMessage, hasData('pong'));
ws.add('hello'); final echoMessage = await ws.stream.first; expect(echoMessage, hasData('echo: hello'));
await ws.close(); await server.close();});
Advanced WebSocket Testing
Section titled “Advanced WebSocket Testing”test('WebSocket events and JSON messages', () async { final server = await ArcadeTestServer.withRoutes(() { route.webSocket('/events').handle((webSocket) { webSocket.listen((message) { final data = jsonDecode(message); if (data['event'] == 'subscribe') { webSocket.add(jsonEncode({ 'event': 'subscribed', 'channel': data['channel'], 'status': 'success' })); } }); }); });
final ws = await server.webSocket('/events');
// Send subscription request ws.add(jsonEncode({ 'event': 'subscribe', 'channel': 'notifications' }));
final response = await ws.stream.first; expect(response, hasEvent('subscribed')); expect(response, hasJsonData({'channel': 'notifications', 'status': 'success'}));
await ws.close(); await server.close();});
State Management Testing
Section titled “State Management Testing”Test Isolation with ArcadeTestState
Section titled “Test Isolation with ArcadeTestState”test('state management and isolation', () async { final server = await ArcadeTestServer.withRoutes(() { route.get('/state').handle((ctx) { final state = ArcadeTestState.instance; return { 'connections': state.connectionCount, 'requests': state.requestCount, }; });
route.post('/increment').handle((ctx) { final state = ArcadeTestState.instance; state.incrementRequestCount(); return {'requests': state.requestCount}; }); });
// Initial state final initialResponse = await server.get('/state'); expect(initialResponse, hasJsonPath('connections', 0)); expect(initialResponse, hasJsonPath('requests', 0));
// Increment counter final incrementResponse = await server.post('/increment'); expect(incrementResponse, hasJsonPath('requests', 1));
// Verify state persists final finalResponse = await server.get('/state'); expect(finalResponse, hasJsonPath('requests', 1));
await server.close();});
Server Lifecycle and Cleanup
Section titled “Server Lifecycle and Cleanup”group('Server lifecycle', () { test('automatic port allocation', () async { final server1 = await ArcadeTestServer.withRoutes(() { route.get('/test').handle((ctx) => 'server1'); });
final server2 = await ArcadeTestServer.withRoutes(() { route.get('/test').handle((ctx) => 'server2'); });
// Each server gets unique port expect(server1.baseUrl, isNot(equals(server2.baseUrl)));
final response1 = await server1.get('/test'); final response2 = await server2.get('/test');
expect(response1, hasTextBody('server1')); expect(response2, hasTextBody('server2'));
await server1.close(); await server2.close(); });
test('proper cleanup on server close', () async { final server = await ArcadeTestServer.withRoutes(() { route.get('/test').handle((ctx) => 'ok'); });
final response = await server.get('/test'); expect(response, isOk());
await server.close();
// Server should no longer be accessible // (In real tests, you might verify this differently) });});
Advanced Usage
Section titled “Advanced Usage”Custom Matchers
Section titled “Custom Matchers”// Create custom matchers for your domainMatcher hasUserStructure() { return allOf([ containsJsonKey('id'), containsJsonKey('name'), containsJsonKey('email'), hasJsonPath('id', isA<int>()), hasJsonPath('email', contains('@')), ]);}
test('custom matcher usage', () async { final server = await ArcadeTestServer.withRoutes(() { route.get('/user').handle((ctx) => { 'id': 1, 'name': 'John Doe', }); });
final response = await server.get('/user'); expect(response, hasJsonBody(hasUserStructure()));
await server.close();});
Testing Middleware and Hooks
Section titled “Testing Middleware and Hooks”test('middleware integration', () async { final server = await ArcadeTestServer.withRoutes(() { // Add before hook route.before((ctx) { ctx.responseHeaders.set('x-middleware', 'processed'); return ctx; });
route.get('/protected').handle((ctx) { return {'message': 'Protected resource'}; }); });
final response = await server.get('/protected');
expect(response, isOk()); expect(response, hasHeader('x-middleware', 'processed')); expect(response, hasJsonPath('message', 'Protected resource'));
await server.close();});
Performance Testing
Section titled “Performance Testing”test('concurrent request handling', () async { final server = await ArcadeTestServer.withRoutes(() { route.get('/slow').handle((ctx) async { await Future.delayed(Duration(milliseconds: 100)); return {'processed': DateTime.now().millisecondsSinceEpoch}; }); });
// Send multiple concurrent requests final futures = List.generate(10, (i) => server.get('/slow')); final responses = await Future.wait(futures);
// All requests should succeed for (final response in responses) { expect(response, isOk()); expect(response, containsJsonKey('processed')); }
await server.close();});
Integration with Arcade Features
Section titled “Integration with Arcade Features”Testing with Dependency Injection
Section titled “Testing with Dependency Injection”test('dependency injection in tests', () async { final server = await ArcadeTestServer.withRoutes(() { // Mock service for testing final mockUserService = MockUserService();
route.get('/users/:id').handle((ctx) { final id = ctx.pathParameters['id']!; final user = mockUserService.getUser(int.parse(id)); return user.toJson(); }); });
final response = await server.get('/users/123'); expect(response, isOk()); expect(response, hasJsonPath('id', 123));
await server.close();});
Testing File Uploads
Section titled “Testing File Uploads”test('file upload handling', () async { final server = await ArcadeTestServer.withRoutes(() { route.post('/upload').handle((ctx) async { final body = await ctx.body(); return { 'received': body.length, 'contentType': ctx.request.headers.contentType?.toString(), }; }); });
final response = await server.post('/upload', body: 'file content data');
expect(response, isOk()); expect(response, hasJsonPath('received', greaterThan(0)));
await server.close();});
Available Matchers Reference
Section titled “Available Matchers Reference”Status Code Matchers
Section titled “Status Code Matchers”// 2xx SuccesshasStatus(int code) // Generic status code matcherisOk() // 200 OKisCreated() // 201 CreatedisNoContent() // 204 No Content
// 4xx Client ErrorsisBadRequest() // 400 Bad RequestisUnauthorized() // 401 UnauthorizedisForbidden() // 403 ForbiddenisNotFound() // 404 Not FoundisMethodNotAllowed() // 405 Method Not AllowedisConflict() // 409 ConflictisImATeapot() // 418 I'm a teapotisUnprocessableEntity() // 422 Unprocessable Entity
// 5xx Server ErrorsisInternalServerError() // 500 Internal Server ErrorisServiceUnavailable() // 503 Service Unavailable
Body Content Matchers
Section titled “Body Content Matchers”hasJsonBody(dynamic expected) // Exact JSON matchhasTextBody(String expected) // Exact text matchhasEmptyBody() // Empty response bodycontainsJsonKey(String key) // JSON object contains keyhasJsonPath(String path, dynamic value) // JSON path-based matching
Header Matchers
Section titled “Header Matchers”hasHeader(String name) // Header existshasHeader(String name, String value) // Header with specific valuehasContentType(String contentType) // Content-Type headerisJson() // Content-Type: application/jsonisHtml() // Content-Type: text/htmlisText() // Content-Type: text/plain
WebSocket Matchers
Section titled “WebSocket Matchers”hasData(String expected) // WebSocket message datahasEvent(String event) // Event-based messagehasJsonData(dynamic expected) // JSON message content
Best Practices
Section titled “Best Practices”- Use
setUpAll
andtearDownAll
: Create and close test servers once per test group - Isolate Test State: Use
ArcadeTestState
for test-specific state management - Test Edge Cases: Include tests for error conditions and edge cases
- Use Descriptive Matchers: Prefer specific matchers like
isNotFound()
overhasStatus(404)
- Test Headers and Content Types: Verify complete response structure
- Mock External Dependencies: Use dependency injection for testable code
- Test WebSocket Lifecycle: Include connection, message exchange, and disconnection tests
- Verify Cleanup: Ensure proper resource cleanup in tearDown methods
Performance Considerations
Section titled “Performance Considerations”Memory Management
Section titled “Memory Management”- Test servers automatically allocate ports and clean up resources
- Close WebSocket connections and servers in tearDown methods
- Use
setUpAll
/tearDownAll
for expensive setup operations
Test Execution Speed
Section titled “Test Execution Speed”- Group related tests to share server setup
- Use lightweight response validation
- Avoid unnecessary delays in test routes
Next Steps
Section titled “Next Steps”- Learn about Basic Routing for route setup patterns
- Explore WebSocket integration for real-time features
- See Error Handling for exception testing strategies
- Check out Request Context for advanced request handling