Testing Guide
Comprehensive guide to testing in Wheels applications using the CLI.
Overview
Wheels CLI provides robust testing capabilities through TestBox integration, offering:
Unit and integration testing
BDD-style test writing
Watch mode for continuous testing
Code coverage reporting
Parallel test execution
Docker-based testing across multiple engines and databases
Test Structure
Directory Layout
/tests/
├── Application.cfc # Test suite configuration
├── specs/ # Test specifications (alternative to folders below)
├── unit/ # Unit tests
│ ├── models/ # Model tests
│ ├── controllers/ # Controller tests
│ └── services/ # Service tests
├── integration/ # Integration tests
├── fixtures/ # Test data files
└── helpers/ # Test utilities
Test File Naming
Follow these conventions:
Model tests:
UserTest.cfc
orUserSpec.cfc
Controller tests:
UsersControllerTest.cfc
Integration tests:
UserFlowTest.cfc
Writing Tests
Basic Test Structure
// tests/unit/models/UserTest.cfc
component extends="testbox.system.BaseSpec" {
function run() {
describe("User Model", function() {
beforeEach(function() {
// Setup before each test
variables.user = model("User").new();
});
afterEach(function() {
// Cleanup after each test
});
it("validates email presence", function() {
variables.user.email = "";
expect(variables.user.valid()).toBeFalse();
expect(variables.user.errors).toHaveKey("email");
});
it("validates email format", function() {
variables.user.email = "invalid-email";
expect(variables.user.valid()).toBeFalse();
expect(variables.user.errors.email).toInclude("valid email");
});
});
}
}
Model Testing
component extends="testbox.system.BaseSpec" {
function run() {
describe("Product Model", function() {
describe("Validations", function() {
it("requires a name", function() {
var product = model("Product").new();
expect(product.valid()).toBeFalse();
expect(product.errors).toHaveKey("name");
});
it("requires price to be positive", function() {
var product = model("Product").new(
name = "Test Product",
price = -10
);
expect(product.valid()).toBeFalse();
expect(product.errors.price).toInclude("greater than 0");
});
});
describe("Associations", function() {
it("has many reviews", function() {
var product = model("Product").findOne();
expect(product).toHaveKey("reviews");
expect(product.reviews()).toBeQuery();
});
});
describe("Scopes", function() {
it("filters active products", function() {
// Create test data
model("Product").create(name="Active", active=true);
model("Product").create(name="Inactive", active=false);
var activeProducts = model("Product").active().findAll();
expect(activeProducts.recordCount).toBe(1);
expect(activeProducts.name).toBe("Active");
});
});
});
}
}
Controller Testing
component extends="testbox.system.BaseSpec" {
function beforeAll() {
// Setup test request context
variables.mockController = prepareMock(createObject("component", "controllers.Products"));
}
function run() {
describe("Products Controller", function() {
describe("index action", function() {
it("returns all products", function() {
// Setup
var products = queryNew("id,name", "integer,varchar", [
[1, "Product 1"],
[2, "Product 2"]
]);
mockController.$("model").$args("Product").$returns(
mockModel.$("findAll").$returns(products)
);
// Execute
mockController.index();
// Assert
expect(mockController.products).toBe(products);
expect(mockController.products.recordCount).toBe(2);
});
});
describe("create action", function() {
it("creates product with valid data", function() {
// Setup params
mockController.params = {
product: {
name: "New Product",
price: 99.99
}
};
// Mock successful save
var mockProduct = createEmptyMock("models.Product");
mockProduct.$("save").$returns(true);
mockProduct.$("id", 123);
mockController.$("model").$args("Product").$returns(
createMock("models.Product").$("new").$returns(mockProduct)
);
// Execute
mockController.create();
// Assert
expect(mockController.flashMessages.success).toInclude("created successfully");
expect(mockController.redirectTo.action).toBe("show");
expect(mockController.redirectTo.key).toBe(123);
});
});
});
}
}
Integration Testing
component extends="testbox.system.BaseSpec" {
function run() {
describe("User Registration Flow", function() {
it("allows new user to register", function() {
// Visit registration page
var event = execute(event="users.new", renderResults=true);
expect(event.getRenderedContent()).toInclude("Register");
// Submit registration form
var event = execute(
event = "users.create",
eventArguments = {
user: {
email: "[email protected]",
password: "SecurePass123!",
passwordConfirmation: "SecurePass123!"
}
}
);
// Verify user created
var user = model("User").findOne(where="email='[email protected]'");
expect(user).toBeObject();
// Verify logged in
expect(session.userId).toBe(user.id);
// Verify redirect
expect(event.getValue("relocate_URI")).toBe("/dashboard");
});
});
}
}
Test Helpers
Creating Test Factories
// tests/helpers/Factories.cfc
component {
function createUser(struct overrides = {}) {
var defaults = {
email: "user#createUUID()#@test.com",
password: "password123",
firstName: "Test",
lastName: "User"
};
defaults.append(arguments.overrides);
return model("User").create(defaults);
}
function createProduct(struct overrides = {}) {
var defaults = {
name: "Product #createUUID()#",
price: randRange(10, 100),
stock: randRange(0, 50)
};
defaults.append(arguments.overrides);
return model("Product").create(defaults);
}
}
Test Data Management
// tests/helpers/TestDatabase.cfc
component {
function setUp() {
// Start transaction
transaction action="begin";
}
function tearDown() {
// Rollback transaction
transaction action="rollback";
}
function clean() {
// Clean specific tables
queryExecute("DELETE FROM users WHERE email LIKE '%@test.com'");
queryExecute("DELETE FROM products WHERE name LIKE 'Test%'");
}
function loadFixtures(required string name) {
var fixtures = deserializeJSON(
fileRead("/tests/fixtures/#arguments.name#.json")
);
for (var record in fixtures) {
queryExecute(
"INSERT INTO #arguments.name# (#structKeyList(record)#)
VALUES (#structKeyList(record, ':')#)",
record
);
}
}
}
Running Tests
Basic Commands
# Run all tests
wheels test run
# Run specific test file
wheels test run tests/unit/models/UserTest.cfc
# Run tests in directory
wheels test run tests/unit/models/
# Run with specific reporter
wheels test run --reporter=json
wheels test run --reporter=junit --outputFile=results.xml
Watch Mode
# Watch for changes and rerun tests
wheels test run --watch
# Watch specific directory
wheels test run tests/models --watch
# Watch with custom debounce
wheels test run --watch --watchDelay=1000
Filtering Tests
# Run by test bundles
wheels test run --bundles=models,controllers
# Run by labels
wheels test run --labels=critical
# Run by test name pattern
wheels test run --filter="user"
# Exclude patterns
wheels test run --excludes="slow,integration"
Code Coverage
Generate Coverage Report
# Generate HTML coverage report
wheels test coverage
# With custom output directory
wheels test coverage --outputDir=coverage-reports
# Include only specific paths
wheels test coverage --includes="models/,controllers/"
Coverage Configuration
In tests/Application.cfc
:
this.coverage = {
enabled: true,
includes: ["models", "controllers"],
excludes: ["tests", "wheels"],
outputDir: expandPath("/tests/coverage/"),
reportFormats: ["html", "json"]
};
Test Configuration
Test Suite Configuration
// tests/Application.cfc
component {
this.name = "WheelsTestSuite" & Hash(GetCurrentTemplatePath());
// Test datasource
this.datasources["test"] = {
url: "jdbc:h2:mem:test;MODE=MySQL",
driver: "org.h2.Driver"
};
this.datasource = "test";
// TestBox settings
this.testbox = {
bundles: ["tests"],
recurse: true,
reporter: "simple",
reportpath: "/tests/results",
runner: ["tests/runner.cfm"],
labels: [],
options: {}
};
}
Environment Variables
# Set test environment
export WHEELS_ENV=testing
# Set test datasource
export WHEELS_TEST_DATASOURCE=myapp_test
# Enable verbose output
export TESTBOX_VERBOSE=true
Testing Best Practices
1. Test Organization
tests/
├── unit/ # Fast, isolated tests
│ ├── models/ # One file per model
│ └── services/ # Service layer tests
├── integration/ # Tests with dependencies
└── e2e/ # End-to-end tests
2. Test Isolation
describe("User Model", function() {
beforeEach(function() {
// Fresh instance for each test
variables.user = model("User").new();
// Clear caches
application.wheels.cache.queries = {};
});
afterEach(function() {
// Clean up test data
if (isDefined("variables.user.id")) {
variables.user.delete();
}
});
});
3. Descriptive Tests
// Good: Descriptive test names
it("validates email format with standard RFC 5322 regex", function() {
// test implementation
});
it("prevents duplicate email addresses case-insensitively", function() {
// test implementation
});
// Bad: Vague test names
it("works", function() {
// test implementation
});
4. AAA Pattern
it("calculates order total with tax", function() {
// Arrange
var order = createOrder();
var item1 = createOrderItem(price: 100, quantity: 2);
var item2 = createOrderItem(price: 50, quantity: 1);
order.addItem(item1);
order.addItem(item2);
// Act
var total = order.calculateTotal(taxRate: 0.08);
// Assert
expect(total).toBe(270); // (200 + 50) * 1.08
});
Continuous Integration
GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup CommandBox
uses: Ortus-Solutions/[email protected]
- name: Install dependencies
run: box install
- name: Run tests
run: |
box server start
wheels test run --reporter=junit --outputFile=test-results.xml
- name: Upload test results
uses: actions/upload-artifact@v2
with:
name: test-results
path: test-results.xml
- name: Generate coverage
run: wheels test coverage
- name: Upload coverage
uses: codecov/codecov-action@v1
Pre-commit Hooks
#!/bin/bash
# .git/hooks/pre-commit
echo "Running tests..."
wheels test run --labels=unit
if [ $? -ne 0 ]; then
echo "Tests failed! Commit aborted."
exit 1
fi
echo "Running linter..."
wheels analyze code
if [ $? -ne 0 ]; then
echo "Code quality check failed!"
exit 1
fi
Debugging Tests
Using Debug Output
it("processes data correctly", function() {
var result = processData(testData);
// Debug output
debug(result);
writeDump(var=result, abort=false);
// Conditional debugging
if (request.debug ?: false) {
writeOutput("Result: #serializeJSON(result)#");
}
expect(result.status).toBe("success");
});
Interactive Debugging
# Run specific test with debugging
wheels test debug tests/unit/models/UserTest.cfc
# Enable verbose mode
wheels test run --verbose
# Show SQL queries
wheels test run --showSQL
Performance Testing
Load Testing
describe("Performance", function() {
it("handles 1000 concurrent users", function() {
var threads = [];
for (var i = 1; i <= 1000; i++) {
arrayAppend(threads, function() {
var result = model("Product").findAll();
return result.recordCount;
});
}
var start = getTickCount();
var results = parallel(threads);
var duration = getTickCount() - start;
expect(duration).toBeLT(5000); // Less than 5 seconds
expect(arrayLen(results)).toBe(1000);
});
});
Common Testing Patterns
Testing Private Methods
it("tests private method", function() {
var user = model("User").new();
// Use makePublic() for testing
makePublic(user, "privateMethod");
var result = user.privateMethod();
expect(result).toBe("expected");
});
Mocking External Services
it("sends email on user creation", function() {
// Mock email service
var mockMailer = createEmptyMock("services.Mailer");
mockMailer.$("send").$returns(true);
// Inject mock
var user = model("User").new();
user.$property("mailer", mockMailer);
// Test
user.save();
// Verify
expect(mockMailer.$times("send")).toBe(1);
expect(mockMailer.$callLog().send[1].to).toBe(user.email);
});
Docker-Based Testing
Wheels provides a comprehensive Docker environment for testing across multiple CFML engines and databases.
Quick Start with Docker
# Start the TestUI and all test containers
docker compose --profile all up -d
# Access the TestUI
open http://localhost:3000
TestUI Features
The modern TestUI provides:
Visual Test Runner: Run and monitor tests in real-time
Container Management: Start/stop containers directly from the UI
Multi-Engine Support: Test on Lucee 5/6 and Adobe ColdFusion 2018/2021/2023
Multi-Database Support: MySQL, PostgreSQL, SQL Server, H2, and Oracle
Pre-flight Checks: Ensures all services are running before tests
Test History: Track test results over time
Container Management
The TestUI includes an API server that allows you to:
Click on any stopped engine or database to start it
Monitor container health and status
View real-time logs
No terminal required for basic operations
Docker Profiles
Use profiles to start specific combinations:
# Just the UI
docker compose --profile ui up -d
# Quick test setup (Lucee 5 + MySQL)
docker compose --profile quick-test up -d
# All Lucee engines
docker compose --profile lucee up -d
# All Adobe engines
docker compose --profile adobe up -d
# All databases
docker compose --profile db up -d
Running Tests via Docker
# Using the CLI inside a container
docker exec -it wheels-lucee5-1 wheels test run
# Direct URL access
curl http://localhost:60005/wheels/testbox?format=json&db=mysql
Database Testing
Test against different databases by using the db
parameter:
# MySQL
wheels test run --db=mysql
# PostgreSQL
wheels test run --db=postgres
# SQL Server
wheels test run --db=sqlserver
# H2 (Lucee only)
wheels test run --db=h2
# Oracle
wheels test run --db=oracle
See Also
wheels test run - Test execution command
wheels test coverage - Coverage generation
wheels generate test - Generate test files
TestBox Documentation - Complete TestBox guide
Docker Testing Guide - Detailed Docker testing documentation
Last updated
Was this helpful?