--- name: testbox-unit-xunit description: "Use this skill when writing xUnit-style tests in TestBox using test functions (testXxx()), setup/teardown lifecycle (beforeTests/afterTests/setup/teardown), the $assert assertion object, grouped assertions with $assert.all() / assertAll(), retrying flaky tests with the retries method or bundle annotation, attaching files with attach(), skipping a single test or an entire test class with the class-level skip annotation, engine detection helpers (isBoxLang/isLucee/isAdobe), or the Arrange-Act-Assert (AAA) pattern for unit testing services, models, and utilities in isolation." applyTo: "**/tests/**/*.{bx,bxm,cfc,cfm,cfml}" --- # xUnit / Unit Testing with TestBox ## When to Use This Skill - Writing xUnit-style test bundles (functions prefixed with `test`) - Using `$assert` assertion methods (isTrue, isEqual, includes, throws, etc.) - Writing `beforeTests()` / `afterTests()` / `setup()` / `teardown()` lifecycle methods - Unit-testing CFC models, services, or utilities in isolation with mocked dependencies - Skipping a single test function or an entire test class - Grouping related assertions with `$assert.all()` so every failure is reported at once - Applying the Arrange-Act-Assert (AAA) pattern --- ## Language Reference | Concept | BoxLang (`.bx`) preferred | CFML (`.cfc`) compatible | |---|---|---| | Class declaration | `class extends="testbox.system.BaseSpec" {}` | `component extends="testbox.system.BaseSpec" {}` | | Test functions | `function testXxx() {}` | `function testXxx() output="false" {}` | | Scoped var | `var x = ...` | `var x = ...` | --- ## Canonical xUnit Bundle Structure ```boxlang class labels="unit" extends="testbox.system.BaseSpec" { /****** LIFECYCLE ******/ // Runs ONCE before all test functions in this bundle function beforeTests() { variables.service = new models.CalculatorService() } // Runs ONCE after all test functions function afterTests() { structClear( variables ) } // Runs before EACH test function function setup() { variables.mockLogger = createMock( "models.Logger" ) variables.service.setLogger( mockLogger ) } // Runs after EACH test function function teardown() { mockLogger.$reset() } /****** TEST METHODS ******/ function testAddsTwoNumbers() { // Arrange var a = 5 var b = 3 // Act var result = service.add( a, b ) // Assert $assert.isEqual( 8, result ) } function testDivideThrowsOnZero() { $assert.throws( () => service.divide( 10, 0 ), "MathException" ) } function testSkipped() skip { $assert.fail( "Should never run" ) } } ``` --- ## Lifecycle Method Reference | Method | When It Runs | Use Case | |---|---|---| | `beforeTests()` | Once before all test functions | Initialize shared objects, DB connections, JWT settings | | `afterTests()` | Once after all test functions | Close connections, delete temp files | | `setup()` | Before **each** test function | Create fresh mocks, reset state, clear caches | | `teardown()` | After **each** test function | Roll back transactions, delete records, reset stubs | ```boxlang function beforeTests() { // One-time: load heavy collaborators variables.orm = getInstance( "ORMService@cborm" ) structClear( request ) } function setup() { // Per-test: always get a clean state variables.mockDAO = createEmptyMock( "models.UserDAO" ) variables.sut = new models.UserService( mockDAO ) } ``` --- ## `$assert` Assertion Reference Every test bundle receives `$assert` — an instance of `testbox.system.Assertion`. ```boxlang // Boolean $assert.isTrue( myBool ) $assert.isFalse( myBool ) // Equality $assert.isEqual( expected, actual ) $assert.isEqualWithCase( expected, actual ) $assert.isNotEqual( expected, actual ) // Null $assert.null( actual ) $assert.notNull( actual ) // Emptiness $assert.isEmpty( target ) // arrays, structs, strings, queries $assert.isNotEmpty( target ) // Size $assert.lengthOf( target, length ) $assert.notLengthOf( target, length ) // Key existence $assert.key( target, key ) $assert.notKey( target, key ) $assert.deepKey( target, key ) $assert.notDeepKey( target, key ) // Inclusion $assert.includes( target, needle ) // case-insensitive $assert.includesWithCase( target, needle ) $assert.notIncludes( target, needle ) $assert.notIncludesWithCase( target, needle ) // Type $assert.typeOf( type, actual ) $assert.notTypeOf( type, actual ) $assert.instanceOf( actual, typeName ) $assert.notInstanceOf( actual, typeName ) // Numeric comparison $assert.isGT( actual, target ) $assert.isGTE( actual, target ) $assert.isLT( actual, target ) $assert.isLTE( actual, target ) $assert.between( actual, min, max ) $assert.closeTo( expected, actual, delta ) // String / regex $assert.match( actual, regex ) $assert.matchWithCase( actual, regex ) $assert.notMatch( actual, regex ) // Exceptions $assert.throws( target, [type], [regex] ) $assert.notThrows( target, [type], [regex] ) // Force failure $assert.fail( [message] ) // Skip current test $assert.skip( message, detail ) ``` ### BoxLang Dynamic Assertion Methods In BoxLang you can also invoke any assertion as a free function prefixed with `assert`: ```boxlang assertIsTrue( myBool ) assertIsEqual( expected, actual ) assertBetween( actual, 1, 100 ) assertThrows( () => badCall(), "MyException" ) ``` --- ## Arrange-Act-Assert (AAA) Pattern ```boxlang function testUserCreation() { // ARRANGE var mockUserDAO = createEmptyMock( "models.UserDAO" ) mockUserDAO.$( "save" ).$results( { id: 42, name: "Alice" } ) var sut = new models.UserService( mockUserDAO ) var data = { name: "Alice", email: "alice@example.com" } // ACT var result = sut.createUser( data ) // ASSERT $assert.isEqual( 42, result.id ) $assert.isEqual( "Alice", result.name ) $assert.isTrue( mockUserDAO.$once( "save" ) ) } ``` --- ## Mixing xUnit with Expectations (expect DSL) You can freely mix `$assert` and `expect()` fluent matchers in the same bundle: ```boxlang function testUserEmail() { var user = sut.findById( 1 ) // xUnit style $assert.isNotEmpty( user ) $assert.key( user, "email" ) // BDD fluent style (also available in xUnit bundles) expect( user.email ).toMatch( ".+@.+" ) expect( user.isActive ).toBeTrue() } ``` --- ## Skipping Tests ```boxlang // Skip via function attribute function testSomething() skip { $assert.fail( "won't run" ) } // Skip via argument function testEngineSpecific() skip="#!server.keyExists( 'lucee' )#" { $assert.isTrue( luceeOnlyFeature() ) } // Skip programmatically inline function testConditional() { if ( !featureEnabled ) { $assert.skip( "Feature flag is off" ) } $assert.isTrue( myFeature.isActive() ) } ``` ### Skipping an Entire Test Class *TestBox 7.1+.* A `skip` annotation on the **class declaration** skips the whole bundle: no test function runs, `beforeTests`/`afterTests` do not fire, and the bundle is left out of dry-run discovery entirely. ```boxlang // BoxLang class extends="testbox.system.BaseSpec" skip="true" { function testNothingRuns() { $assert.fail( "never reached" ) } } ``` ```cfml // CFML component extends="testbox.system.BaseSpec" skip="true" { function testNothingRuns(){ $assert.fail( "never reached" ); } } ``` The annotation also accepts a **method name on the class**, which TestBox invokes to decide: ```boxlang /** * Skipped off BoxLang: this bundle exercises BoxLang-only Set behaviour. */ class extends="testbox.system.BaseSpec" skip="isBoxLangMissing" { function isBoxLangMissing() { return !isBoxLang() } function testSetBehaviour() { $assert.isASet( setOf( 1, 2, 3 ) ) } } ``` | `skip` value | Effect | |---|---| | `skip="true"` (or a boolean expression) | Always skip the bundle | | `skip=""` (present, empty) | Always skip the bundle | | `skip="methodName"` | Call `methodName()` on the class; skip when it returns true | | annotation absent | Run normally | `BaseSpec` supplies the engine predicates these guards usually need: `isBoxLang()`, `isLucee()`, `isAdobe()`, `isWindows()`, `isLinux()`, `isMac()`. > **TestBox 7.1 fix:** `isLucee()` used to return `true` under BoxLang, since BoxLang's CFML > compatibility layer also populates `server.lucee`. It now returns `false` on BoxLang — prefer > `isLucee()` over a hand-rolled `structKeyExists( server, "lucee" )` check, which still misfires. --- ## Grouped Assertions — `$assert.all()` *TestBox 7.1+.* A test function stops at its first failed assertion. When several assertions describe **one** outcome, `$assert.all()` runs every closure and reports all the failures in a single aggregated error instead: ```boxlang function testUserShape() { // Arrange / Act var user = variables.service.create( { name: "Alice", email: "alice@example.com" } ) // Assert — every failure reported at once $assert.all( [ () => $assert.key( user, "id" ), () => $assert.isEqual( "Alice", user.name ), () => $assert.isEqual( "alice@example.com", user.email ), () => $assert.typeOf( "date", user.createdAt ) ], "User shape" ) } ``` `assertAll( executables, [heading] )` is the same call under a shorter name. See the [`testbox-assertions`](../assertions/SKILL.md) skill for the full contract. --- ## Retrying Flaky Tests and Attaching Files *TestBox 7.2+.* A `retries` annotation on a test method (or on the bundle class) reruns a failing or erroring test up to N **extra** times. Each attempt reruns `setup()`, the test and `teardown()`; only the final attempt is recorded. Skipped tests are never retried. ```boxlang class extends="testbox.system.BaseSpec" retries="1" { // bundle default function testChargesACard() retries="3" { // method annotation wins var receipt = variables.gateway.charge( 100 ) attach( receipt.pdfPath, "file", "receipt.pdf" ) // attach( path, type, name ) $assert.isEqual( "paid", receipt.status ) } } ``` Precedence: method `retries` > bundle `retries` annotation > runner `--retries=N`. `bx-playwright` assertion failures (`Playwright.AssertionFailed`) count as test **failures**, not errors. For browser tests see the [`testbox-browser-testing`](../browser-testing/SKILL.md) skill. --- ## Custom Assertions Register in `beforeTests()` to keep the shared `$assert` object clean: ```boxlang function beforeTests() { addAssertions( { isValidEmail: function( actual ) { return ( reFindNoCase( "^[^@]+@[^@]+\.[^@]+$", actual ) > 0 ? true : fail( "[#actual#] is not a valid email address" ) ) }, isUUID: function( actual ) { return ( isValid( "uuid", actual ) ? true : fail( "[#actual#] is not a UUID" ) ) } } ) } function testEmailValidator() { $assert.isValidEmail( "alice@example.com" ) $assert.isValidEmail( "not-an-email" ) // will fail } ``` For reusable assertion libraries, register a class path or instance: ```boxlang function beforeTests() { addAssertions( "tests.helpers.CustomAssertions" ) // or addAssertions( new tests.helpers.CustomAssertions() ) } ``` --- ## Key Differences: xUnit vs BDD | Aspect | xUnit | BDD | |---|---|---| | Test declaration | `function testXxx()` | `it( "...", () => {} )` | | Suite declaration | Class-level | `describe( "...", () => {} )` | | Lifecycle | `beforeTests/setup/teardown/afterTests` | `beforeAll/beforeEach/afterEach/afterAll/aroundEach` | | Assertions | `$assert.isXxx()` | `expect().toBeXxx()` | | Skip a test | `skip` function attribute | `xit()`, `skip()` inline | | Skip a whole class | class-level `skip` annotation (7.1+) | class-level `skip` annotation (7.1+) | | Group assertions | `$assert.all()` (7.1+) | `assertAll()` (7.1+) | | Nesting | Not supported | Unlimited nested `describe` blocks | | Data binding | Not supported | `it( data={} )` | --- ## CommandBox Scaffolding ```bash # Create xUnit spec coldbox create unit name=UserServiceTest open=true # Scaffold with specific model binding coldbox create unit name=UserServiceTest methods=testCreate,testUpdate,testDelete ```