boxlang-testing について
BoxLang 標準のテストフレームワーク TestBox を使うテストスキルです。開発者や AI エージェントが BDD または xUnit 形式のテストを書き、アサーションや MockBox を利用できます。
主な機能
- 01BDD と xUnit のテスト形式
- 02チェーン可能な期待値とアサーション
- 03MockBox によるモックと呼び出し検証
- 04モックデータの生成
- 05スペックの非同期実行
- 06スペックのスキップ、フォーカス、再試行
活用シーン
- 01BoxLang アプリケーションの動作をテスト
- 02サービスやリポジトリの依存先をモック
- 03テスト用フィクスチャデータを生成
- 04テストスペックを並行実行
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ ortus-boxlang/ skills --skill boxlang-testingインストール後、Claude Code で/boxlang-testingを入力して読み込まれたか確認します。
詳細
BoxLang と TestBox によるテスト
BoxLang は、開発者と AI エージェントが協力してアプリケーションを構築、モダナイズ、実行するための、AI ネイティブなソフトウェア生産性プラットフォームです。
概要
TestBox は BoxLang の標準テストフレームワークです。次の 2 つのスタイルに対応しています。
- BDD(振る舞い駆動開発):
describe()、it()、feature()、story()、given()/when()/then() - xUnit:クラスベースの
test*()メソッド、setup()/tearDown()
どちらのスタイルでも、同じアサーション/期待値ライブラリと、モック作成用の MockBox を使用します。
テストバンドル
テストバンドルは、テストを含む BoxLang クラス(.bx ファイル)です。慣例として、ファイル名には Test または Spec の接尾辞を付けます。例:UserServiceSpec.bx、OrderServiceTest.bx。
// tests/specs/UserServiceSpec.bx
class extends="testbox.system.BaseSpec" {
function run() {
describe( "UserService", () => {
it( "can greet a user", () => {
expect( "hello" ).toBe( "hello" )
} )
} )
}
}
testbox.system.BaseSpec の継承は任意ですが、推奨されています。継承すると、IDE によるイントロスペクション、ウェブランナーからの直接実行、テスト読み込みの高速化が可能になります。
インジェクションされる変数(バンドルで常に使用可能)
| 変数 | 用途 |
|---|---|
$mockbox | モック作成用の MockBox インスタンス |
$assert | アサーションライブラリ |
$utility | ユーティリティヘルパー |
$customMatchers | カスタムマッチャーのレジストリ |
$testID | バンドルの一意な ID |
$debugBuffer | デバッグ出力バッファー |
BDD スタイル
基本構造
class extends="testbox.system.BaseSpec" {
function run() {
describe( "Calculator", () => {
it( "can add two numbers", () => {
var calc = new Calculator()
expect( calc.add( 1, 2 ) ).toBe( 3 )
} )
it( "throws on divide by zero", () => {
expect( () => new Calculator().divide( 10, 0 ) ).toThrow()
} )
} )
}
}
スイート関数(すべて同等のブロック)
| 関数 | 別名/用途 |
|---|---|
describe( title, body ) | 標準スイート |
feature( title, body ) | フィーチャーレベルのスイート |
story( title, body ) | ストーリーレベルのスイート |
scenario( title, body ) | フィーチャー/ストーリー内のシナリオ |
given( title, body ) | Given ブロック(GWT) |
when( title, body ) | When ブロック(GWT) |
then( title, body ) | Then ブロック/it() の別名 |
feature( "User authentication", () => {
scenario( "valid credentials", () => {
given( "a registered user", () => {
when( "they log in with correct password", () => {
then( "they should receive a JWT token", () => {
var token = authService.login( "[email protected]", "secret" )
expect( token ).notToBeEmpty()
} )
} )
} )
} )
} )
Spec 関数
// it() is the primary spec block
it( "does something", () => {
expect( true ).toBeTrue()
} )
// then() is an alias for it(): title arg is "then" instead of "title"
then( "something should happen", () => {
expect( result ).notToBeNull()
} )
ライフサイクルメソッド(BDD)
ライフサイクルメソッドは、spec の前後に実行するセットアップと後処理を制御します。
グローバル(バンドル全体)
class extends="testbox.system.BaseSpec" {
// Runs ONCE before all specs in the bundle
function beforeAll() {
variables.db = new Database()
variables.db.connect()
}
// Runs ONCE after all specs in the bundle
function afterAll() {
variables.db.disconnect()
directoryDelete( "/tests/tmp", true )
}
function run() { ... }
}
スイートレベル(BDD:beforeEach/afterEach/aroundEach)
describe( "UserService", () => {
beforeEach( ( currentSpec, data ) => {
variables.service = new UserService()
} )
afterEach( ( currentSpec, data ) => {
variables.service = javaCast( "null", "" )
} )
aroundEach( ( spec, suite, data ) => {
// wraps the spec; you MUST call spec.body() yourself
transaction action="begin" {
arguments.spec.body()
transaction action="rollback"
}
} )
it( "can find a user by id", () => {
var user = variables.service.findById( 1 )
expect( user ).toHaveKey( "name" )
} )
} )
beforeEachはネストツリーを下へ(外側 → 内側)たどります。afterEachはツリーを上へ(内側 → 外側)たどります。
ライフサイクルアノテーション(親クラス用)
名前付きメソッドをオーバーライドせずにライフサイクルへフックするには、任意の関数にアノテーションを付けます。 ベーステストクラスで役立ちます。
class extends="testbox.system.BaseSpec" {
/**
* @beforeAll
*/
function initAppContext() {
// Called once before all specs; discovered up the inheritance chain
}
/**
* @aroundEach
*/
function wrapInTransaction( spec, suite ) {
transaction action="begin" {
try {
arguments.spec.body()
} finally {
transaction action="rollback"
}
}
}
}
ライフサイクルのデータバインディング
ループ内で動的な値を正しく捕捉するには、ライフサイクルメソッドにデータを渡します。
var fixtures = [ "admin", "guest", "editor" ]
for ( var role in fixtures ) {
describe( "Role: #role#", () => {
beforeEach(
data = { roleName = role },
body = ( currentSpec, data ) => {
variables.user = userFactory.create( data.roleName )
}
)
it(
title = "has correct permissions",
data = { role = role },
body = ( data ) => {
expect( variables.user.getRole() ).toBe( data.role )
}
)
} )
}
xUnit スタイル
@DisplayName "OrderService Tests"
class extends="testbox.system.BaseSpec" {
// Runs ONCE before all tests
function beforeTests() {
application.dbPool = createDBPool()
}
// Runs ONCE after all tests
function afterTests() {
structClear( application )
}
// Runs before EACH test method
function setup() {
variables.service = new OrderService()
}
// Runs after EACH test method
function tearDown() {
variables.service = javaCast( "null", "" )
}
// Any function with "test" prefix is a test
function testCalculateTotalWithDiscount() {
var total = variables.service.calculateTotal( items=[], discount=0.1 )
$assert.isEqual( total, 0 )
}
// Or use @test annotation for any naming
@DisplayName "It should reject empty orders"
@test
function itRejectsEmptyOrders() {
expect( () => variables.service.place( [] ) ).toThrow( type="EmptyOrderException" )
}
// Skip a specific test
function testSkipped() skip {
$assert.fail( "should not run" )
}
}
期待値(流れるような API)
expect( actual ) を使用して、流れるようなアサーションチェーンを開始します。否定形にするには、任意のマッチャーの前に not を付けます。
一般的なマッチャー
// Equality
expect( result ).toBe( expected )
expect( result ).notToBe( expected )
expect( "Hello" ).toBeWithCase( "Hello" )
// Boolean
expect( isActive ).toBeTrue()
expect( isDeleted ).toBeFalse()
// Null
expect( value ).toBeNull()
expect( value ).notToBeNull()
// Type checks
expect( items ).toBeArray()
expect( config ).toBeStruct()
expect( id ).toBeNumeric()
expect( dob ).toBeDate()
expect( obj ).toBeComponent()
expect( obj ).toBeInstanceOf( "com.example.MyClass" )
expect( obj ).toBeTypeOf( "uuid" ) // uses isValid()
// Size / emptiness
expect( [] ).toBeEmpty()
expect( items ).notToBeEmpty()
expect( items ).toHaveLength( 3 )
expect( "Hello" ).toHaveLength( 5 )
// Struct keys
expect( user ).toHaveKey( "name" )
expect( config ).toHaveDeepKey( "database.host" )
// Strings / arrays
expect( message ).toMatch( "^Error" ) // regex, case-insensitive
expect( message ).toMatchWithCase( "ERROR" ) // regex, case-sensitive
expect( items ).toInclude( "admin" ) // case-insensitive
expect( items ).toIncludeWithCase( "Admin" ) // case-sensitive
// Numbers / dates
expect( value ).toBeGT( 0 )
expect( value ).toBeGTE( 1 )
expect( value ).toBeLT( 100 )
expect( value ).toBeLTE( 99 )
expect( value ).toBeBetween( 1, 100 )
expect( score ).toBeCloseTo( 3.14, 0.01 )
// Exceptions (ALWAYS use a closure)
expect( () => service.doThing() ).toThrow()
expect( () => service.doThing() ).toThrow( type="MyException" )
expect( () => service.doThing() ).toThrow( regex="not found" )
expect( () => service.doThing() ).toThrow( type="MyException", regex="not found" )
チェーン
// Multiple matchers on the same value
expect( result )
.notToBeNull()
.toBeStruct()
.toHaveKey( "id" )
.toHaveKey( "name" )
コレクションの期待値(expectAll)
配列または構造体のすべての要素を一度にテストします。
expectAll( users ).toHaveKey( "name" )
expectAll( scores ).toBeGT( 0 )
expectAll( [ true, true, true ] ).toBeTrue()
expectAll( { a: 2, b: 4, c: 6 } ).toSatisfy( (x) -> x mod 2 == 0 )
例外テストのパターン
// Preferred: closure-based
it( "throws on invalid input", () => {
expect( () => {
service.process( javaCast( "null", "" ) )
} ).toThrow( type="InvalidArgumentException" )
} )
// xUnit annotation style
function testThrowsOnNull() expectedException="InvalidArgumentException" {
service.process( javaCast( "null", "" ) )
}
アサーションライブラリ($assert)
すべてのバンドルで $assert を通じて利用できる従来のアサーションライブラリです。
$assert.isTrue( condition, [message] )
$assert.isFalse( condition, [message] )
$assert.isEqual( expected, actual, [message] )
$assert.isNotEqual( expected, actual, [message] )
$assert.isNull( value, [message] )
$assert.isNotNull( value, [message] )
$assert.isEmpty( value, [message] )
$assert.isNotEmpty( value, [message] )
$assert.includes( collection, needle )
$assert.notIncludes( collection, needle )
$assert.includesWithCase( collection, needle )
$assert.throws( closure, [type], [regex], [message] )
$assert.fail( [message] )
// Dynamic assertion methods (BoxLang only)
assertIsTrue( condition )
assertIsEqual( expected, actual )
assertIsNull( value )
// Any $assert method can be called as assert{MethodName}()
スキップとフォーカス
Spec/スイートのスキップ
スイート/spec 関数の先頭に x を付けるか、skip 引数を使用します。
// Prefix approach: skip entire suite
xdescribe( "Slow integration tests", () => { ... } )
// Prefix approach: skip single spec
xit( "is temporarily disabled", () => { ... } )
// skip argument: boolean or closure
it( title="runs only on production", skip=!isProduction(), body=() => { ... } )
describe( title="Lucee only", skip=() => !structKeyExists( server, "lucee" ), body=() => {
it( "uses Lucee ORM", () => { ... } )
} )
// skip() method inside spec: programmatic
it( "can run on multiple engines", () => {
if ( !server.keyExists( "boxlang" ) ) {
skip( "BoxLang-only feature" )
}
// ... rest of spec
} )
Spec/スイートへのフォーカス
先頭に f を付けるか、focused=true を使用します。フォーカスした spec/スイートのみが実行されます。
// Focus a single spec
fit( "is the only spec that runs", () => {
expect( true ).toBeTrue()
} )
// Focus a suite (all children run)
fdescribe( "Critical path", () => {
it( "step one", () => { ... } )
it( "step two", () => { ... } )
} )
警告:フォーカスしたテストは絶対にコミットしないでください。CI ではそれらの spec のみが実行されます。
MockBox によるモック作成
MockBox は TestBox に組み込まれています。ヘルパーメソッドはすべてのバンドルで利用できます。
モックの作成
// Create a mock of a class (real methods unless overridden)
var mockService = createMock( "services.UserService" )
// Create an empty mock (ALL methods removed: you mock everything)
var mockRepo = createEmptyMock( "repositories.UserRepository" )
// Decorate an existing instance with mocking capabilities
var realService = new services.UserService()
prepareMock( realService )
// Chainable setup
var mockUser = createMock( "model.User" )
.$( "isActive", true )
.$( "getRole", "admin" )
.$( "getName", "Luis Majano" )
メソッドのモック:$()
// Return a single value
mockRepo.$( "findById", { id: 1, name: "Luis" } )
// Return nothing (void)
mockRepo.$( "deleteById" )
// Throw an exception
mockRepo.$( "findById",
throwException = true,
throwType = "NotFoundException",
throwMessage = "Record not found"
)
// Use a callback for dynamic responses
mockRepo.$( method="findById", callback=( id ) => {
if ( id == 1 ) return { id: 1, name: "Luis" }
return javaCast( "null", "" )
} )
戻り値の順次指定:$results()
値を順番に返し、最後まで返すと先頭に戻ります。
// First call → true, second call → false, third → true (repeats)
mockSession.$( "isLoggedIn" ).$results( true, false )
expect( mockSession.isLoggedIn() ).toBeTrue() // 1st call
expect( mockSession.isLoggedIn() ).toBeFalse() // 2nd call
expect( mockSession.isLoggedIn() ).toBeTrue() // 3rd call (cycles)
引数に応じたモック:$args()
渡された引数に応じて異なる値を返します。
mockCache.$( "get" )
.$args( "user-1" ).$results( { id: 1, name: "Luis" } )
.$args( "user-2" ).$results( { id: 2, name: "Alexia" } )
.$args( "missing" ).$results( javaCast( "null", "" ) )
プロパティのモック:$property()
// Mock a property on the object
mockService.$property( "timeout", "variables", 5000 )
mockService.$property( "cache", "instance", mockCache )
クエリのシミュレーション:$querySim()
var mockQuery = mockDAO.$querySim(
"id, name, email
1 | Luis Majano | [email protected]
2 | Alexia Majano | [email protected]"
)
mockDAO.$( "getUsers", mockQuery )
検証メソッド
モック化したメソッドの呼び出し方と呼び出し回数を確認します。
// Get call count
mockRepo.$count( "findById" ) // calls to findById specifically
mockRepo.$count() // total calls to all mocked methods
// Assert call counts
expect( mockRepo.$times( 1, "findById" ) ).toBeTrue() // called exactly once
expect( mockRepo.$once( "save" ) ).toBeTrue() // alias for $times(1)
expect( mockRepo.$never( "delete" ) ).toBeTrue() // never called
expect( mockRepo.$atLeast( 2, "findById" ) ).toBeTrue()
expect( mockRepo.$atMost( 3, "findById" ) ).toBeTrue()
// Inspect call log (arguments per call)
var log = mockRepo.$callLog()
expect( log.findById[ 1 ].id ).toBe( 42 ) // 1st call, named arg "id"
// Reset all mock state
mockRepo.$reset()
モック作成の完全な例
class extends="testbox.system.BaseSpec" {
function beforeAll() {
// Create service under test with mocked dependencies
variables.mockUserDAO = createEmptyMock( "repositories.UserDAO" )
variables.mockEmailSvc = createEmptyMock( "services.EmailService" )
variables.service = createMock( "services.UserService" )
variables.service.init( variables.mockUserDAO, variables.mockEmailSvc )
}
function run() {
describe( "UserService.register()", () => {
beforeEach( () => {
variables.mockUserDAO.$reset()
variables.mockEmailSvc.$reset()
} )
it( "creates user and sends welcome email", () => {
var newUser = { id: 99, email: "[email protected]", name: "Test" }
variables.mockUserDAO.$( "create", newUser )
variables.mockEmailSvc.$( "sendWelcome" )
var result = variables.service.register( "[email protected]", "Test" )
expect( result ).toBe( newUser )
expect( variables.mockUserDAO.$once( "create" ) ).toBeTrue()
expect( variables.mockEmailSvc.$once( "sendWelcome" ) ).toBeTrue()
} )
it( "throws when email already exists", () => {
variables.mockUserDAO.$( "existsByEmail", true )
expect( () => variables.service.register( "[email protected]", "Test" ) )
.toThrow( type="DuplicateEmailException" )
} )
} )
}
}
モックデータ生成(mockData())
TestBox に含まれる cbMockData モジュールを通じて、すべてのバンドルで利用できます。
// Generate 5 fake user records
var users = mockData(
$num = 5,
id = "autoincrement",
name = "name",
email = "email",
age = "age",
isActive = "boolean",
createdDate = "datetime",
bio = "lorem:2" // 2 paragraphs of lorem ipsum
)
// Random count
var items = mockData( $num = "rnd:5:20", title = "name" )
// Return as struct instead of array
var single = mockData(
$returnType = "struct",
$num = 1,
id = "uuid",
name = "name",
email = "email"
)
利用可能なモックの種類
| 種類 | 説明 |
|---|---|
autoincrement | 1から始まる連続した整数 |
uuid | ランダムなUUID |
name | フルネーム |
fname / lname | 名/姓 |
email | メールアドレス |
age | 成人年齢(18~75歳) |
all_age | 任意の年齢(1~100歳) |
boolean | true または false |
boolean-digit | 0 または 1 |
date / datetime | ランダムな日付/日時 |
datetime-iso | ISO 8601形式の日時 |
num / num:X / num:X:Y | ランダムな数値 |
lorem:N | lorem ipsumのN段落 |
baconlorem:N | bacon loremのN段落 |
ipaddress | IPv4アドレス |
imageurl / imageurl_https | 画像URL |
website | WebサイトURL |
phone | 電話番号 |
oneof:a:b:c | 指定された値のいずれか1つ |
非同期テスト
asyncAll=trueを使用して、スイート内のすべてのテストを同時に実行します。
describe( title="Parallel specs", asyncAll=true, body=() => {
beforeEach( () => {
variables.service = new MyService()
} )
it( "spec one", () => {
expect( variables.service.ping() ).toBeTrue()
} )
it( "spec two", () => {
expect( variables.service.version() ).notToBeEmpty()
} )
} )
警告:非同期モードでは、すべての変数を適切に
varスコープにするか、スレッドセーフにする必要があります。非同期テストの実行時に、共有のvariablesスコープは使用しないでください。
添付ファイル、再試行、ブラウザーテスト
テストにファイルを添付:attach()
すべてのテストで、実行中のテストにファイル(スクリーンショット、ログ、ダンプ)を添付できます。ファイルは成功したテストと失敗したテストの両方に保持され、レポーターに表示されます(JSONレポート、Simpleレポート内のリンク、JUnitの<system-out>内の[[ATTACHMENT|path]]行、失敗したテストの下に表示されるテキスト/コンソール/ストリーム出力)。
it( "exports the report", () => {
var file = service.export()
attach( file, "file", "export.csv" ) // attach( path, type = "file", name = file name of path )
attach( logPath, "log" )
expect( fileExists( file ) ).toBeTrue()
} )
テスト本体、またはbeforeEach()/afterEach()/aroundEach()クロージャーから呼び出してください。実行中のテスト以外で呼び出すと、TestBox.InvalidContextがスローされます。
再試行
失敗またはエラーになったテストは、最大N回追加で再実行できます。各試行ではbeforeEach()、本体、afterEach()(xUnitの場合はsetup()、テスト、teardown())が再実行され、最終試行のみが記録されます。出力には「(N回の試行後に成功)」と表示されます。
// 1. Spec argument (highest precedence), also on fit() and xit()
it( title = "talks to a flaky service", retries = 2, body = () => { ... } )
// 2. Bundle annotation (BoxLang annotations go above the declaration)
@retries( 1 )
class extends="testbox.system.BaseSpec" { ... }
// xUnit: method annotation overrides the bundle annotation
@retries( 3 )
function testFlaky() { ... }
// CFML components keep inline attributes: component extends="testbox.system.BaseSpec" retries="1" { ... }
# 3. Global default (lowest precedence)
./testbox/run --retries=2
スキップされたテストは再試行されません。再試行は、実際に不安定なI/O(ブラウザー、ネットワーク)に使用し、バグを隠すために使わないでください。
失敗したテストのみを再実行
results.getFailedTargets()は、すべてのエンジンで実行時に失敗した内容を返します:{ bundles, specs, bundleErrors }(バンドルのパス、テストID、テスト外で失敗したバンドル)。既存のフィルターを使用して、それらのみを実行します。
var failed = results.getFailedTargets()
new testbox.system.TestBox( bundles = failed.bundles ).runRaw( testBundles = failed.bundles, testSpecs = failed.specs )
- HTMLレポート(Simple、Min、Dot、Doc):失敗があった場合のみ、Run Allの横に**Run Failed (N)**ボタンが表示されます。リンク(
?testBundles=...&testSpecs=...)はレポート自体から生成されるため、Webランナーは状態を保持しません。2000文字を超えると、失敗したバンドル全体を再実行します。 - BoxLang CLIの
--failed:以下を参照してください。
--failed
BoxLangの各ランナー実行では、失敗またはエラーになったバンドルとテストを含む{reportpath}/.testbox-failed.jsonが書き込まれます。./testbox/run --failedはそれらのみを再実行します。ファイルが存在しないか空の場合は、メッセージを表示して何も実行しません。テスト外(beforeAll()、afterAll())で失敗したバンドルは、不安定なのではなく壊れているため、ファイル内のbundleErrorsに追加され、一覧表示されますが、再実行されません。それ以外のバンドルでは、失敗したテストのみが再実行されます。
Playwrightの失敗
bx-playwrightのアサーション(page.assertSee()など)がスローするPlaywright.AssertionFailedは、TestBox.AssertionFailedと同様にテストの失敗として扱われ、そのメッセージと詳細が保持されます。それ以外のPlaywright.*例外はエラーとして扱われます。
ブラウザーテスト
ブラウザーテストでは、class extends="testbox.system.BaseSpec"(ColdBoxアプリではcoldbox.system.testing.BaseTestCase)の上に@browser(または@browserProfile( "ci" )/@baseURL( "http://..." ))を付与し、browse()、ブラウザーマッチャー(expect( page ).toSee( "Welcome" ))、およびランナーオプションの--web-serverを使用します。APIの全容については、bx-playwright-testingスキルを読み込んでください。
テストの実行
BoxLang CLIランナー
# Run all tests (default: tests/specs)
./testbox/run
# Run a specific bundle (dot notation)
./testbox/run tests.specs.UserServiceSpec
# Run all specs in a directory
./testbox/run --directory=tests.specs.unit
# Specify a bundle pattern
./testbox/run --bundles-pattern=*Spec*.bx
# Stream results in real-time (great for CI)
./testbox/run --stream
# Stop on first failure
./testbox/run --eager-failure
# Verbose output (every spec printed)
./testbox/run --verbose
# Retry failing specs, or rerun only the last failures
./testbox/run --retries=2
./testbox/run --failed
# Start the app before the tests (browser tests)
./testbox/run --web-server="boxlang-miniserver --port 8080" --web-server-url=http://localhost:8080
CLI ランナーのオプション
| オプション | デフォルト | 説明 |
|---|---|---|
--bundles | * | ドット記法のバンドル一覧 |
--bundles-pattern | *Spec*.bx|*Test*.bx|... | グロブパターン |
--directory | tests.specs | スキャンするディレクトリ |
--recurse | true | サブディレクトリも再帰的に処理します |
--eager-failure | false | 最初の失敗で停止します |
--verbose | false | 各スペックの実行時に出力します |
--stream | false | リアルタイムで出力をストリーミングします |
--retries | 0 | 失敗またはエラーになったスペックの追加実行回数(スペックとバンドルの値が優先されます) |
--failed | false | 前回の実行で失敗またはエラーになったバンドルとスペックのみを実行します |
--web-server | テスト前にウェブサーバーを起動し、テスト後に停止するシェルコマンド | |
--web-server-url | http://localhost:8080 | サーバーから応答があるまでポーリングする URL。ブラウザー対応(@browser)バンドルのデフォルト baseURL です |
--web-server-timeout | 60 | サーバーを待つ秒数。応答がない場合はコード 1 で終了します |
CommandBox ランナー
box testbox run
box testbox run bundles=tests.specs.UserServiceSpec
box testbox run --verbose
レポーター
ランナー URL の reporter=、BoxLang CLI ランナーの --reporter=、またはコード内の reporter = { type : "...", options : {} } で選択します。
| レポーター | 出力 |
|---|---|
Simple | 完全な HTML レポート。失敗を先頭に表示し、すべてのバンドルと Ask AI およびエディターへのリンクを含みます |
Min / Dot / Doc | HTML:要対応項目のみ/スペックごとにドット 1 つと詳細ドロワー/ドキュメント形式 |
Text、MinText、Console | プレーンテキスト |
JSON、Raw、XML | 全結果をデータとして出力 |
JUnit、ANTJunit | CI 用の JUnit XML(アタッチメントは <system-out> 内の [[ATTACHMENT|path]] 行として出力) |
Agent | AI エージェント向けのコンパクトな JSON:合計数と失敗のみ |
HTML レポーター(Simple、Min、Dot、Doc)
- 最初に判定バナーを表示します(件数、割合バー、ステータスフィルター)。ライト、ダーク、システムのテーマに対応し、
Fで次の失敗に移動、/で検索できます。 - すべてのバンドル、スイート、スペックに Run リンクがあり、Run All と、失敗がある場合は Run Failed (N) も利用できます(レポートから生成され、状態は保存されません。「失敗のみ再実行」を参照)。
?editor=vscode(デフォルト)やその他のエディターを指定すると、失敗箇所やスタックフレームがエディターで開くリンクになります。- すべての失敗に Ask AI が表示されます。プロンプトをコピーまたはプレビューし、ChatGPT または Claude で開くか、Agent JSON としてコピーできます。プロバイダーをクリックするまでは、ページ外にデータが送信されることはありません。
reporter = {
type : "testbox.system.reports.SimpleReporter",
options : {
aiAssist : true, // false (or ?aiAssist=false) removes Ask AI
aiContextLines : 5, // code lines around the failing line
aiStackFrames : 8,
aiProviders : [ { id : "acme", name : "Acme AI", url : "https://ai.acme.test/?p={prompt}" } ],
aiPrompt : "{intro} {spec} ({status}) {message} {code} {stack} Rerun: {rerun}",
urlParams : { editor : "idea" } // request params for reports produced from code (no url scope)
}
}
Agent レポーター(reporter=agent)
1 行の最小化された JSON を出力します:{"ok":false,"totals":{"pass","fail","error","skipped","specs","ms"},"failures":[{"bundle","spec","status","message","at"}],"truncated":0}。ok に基づいて分岐します。終了コードは変更されません。オプション:detail(summary、failures、all)、maxFailures(20)、maxMessageLength(300)、includeStack(false)、stackDepth(3)、includeSkipped、includeDebug。エージェントがテストを実行する場合は、こちらを推奨します:./testbox/run --reporter=agent。
テストハーネスの構成
tests/
├── specs/
│ ├── unit/
│ │ ├── UserServiceSpec.bx
│ │ └── OrderServiceSpec.bx
│ ├── integration/
│ │ └── UserFlowSpec.bx
│ └── resources/
│ └── fixtures.json
├── Application.bx ← configures mappings for tests
└── runner.bxm ← web runner template
テスト用の最小限の Application.bx
class {
this.name = "MyAppTests"
this.mappings[ "/testbox" ] = expandPath( "/testbox" )
this.mappings[ "/app" ] = expandPath( "../src" )
}
よくあるパターン
依存関係を持つクラスのテスト(コンストラクターインジェクション)
function beforeAll() {
variables.mockRepo = createEmptyMock( "UserRepository" )
variables.mockMailer = createEmptyMock( "Mailer" )
variables.service = new UserService( variables.mockRepo, variables.mockMailer )
}
prepareMock を使ったプライベートメソッドのテスト
it( "validates email format internally", () => {
var svc = prepareMock( new UserService() )
// expose private method for targeted testing
expect( svc.validateEmail( "bad-email" ) ).toBeFalse()
expect( svc.validateEmail( "[email protected]" ) ).toBeTrue()
} )
データ駆動型スペック
var validInputs = [ "hello", "world", "boxlang" ]
for ( var word in validInputs ) {
it(
title = "validates '#word#' as a valid slug",
data = { word = word },
body = ( data ) => {
expect( SlugValidator.isValid( data.word ) ).toBeTrue()
}
)
}
クエリ結果のテスト
it( "returns paginated user query", () => {
var mockQ = mockDAO.$querySim(
"id, name
1 | Luis
2 | Alexia"
)
mockDAO.$( "paginate", mockQ )
var result = variables.service.getPage( 1, 10 )
expect( result ).toBeQuery()
expect( result.recordCount ).toBe( 2 )
} )
テスト間でのモックのリセット
状態の引き継ぎを防ぐため、beforeEach でモックを必ずリセットします:
beforeEach( () => {
variables.mockRepo.$reset()
variables.mockMailer.$reset()
} )
避けるべきアンチパターン
| アンチパターン | より良いアプローチ |
|---|---|
| 実装の詳細(非公開の内部処理)をテストする | 公開された動作をテストし、prepareMock は必要最小限に使用します |
| スペック間で変更可能な状態を共有する | beforeEach でリセットし、afterEach でクリーンアップします |
fit() / fdescribe() をバージョン管理にコミットする | フォーカスしたスペックはローカルでのみ使用し、CI ではすべて実行します |
| 自然にまとめられるのに、スペックごとにアサーションを 1 つだけにする | 関連するアサーションは 1 つのスペックにまとめ、論理的に無関係なスペックは分割します |
| スペック間でモックの呼び出しログをリセットしない | beforeEach で $reset() を呼び出します |
共有された variables スコープで asyncAll=true を使用する | 非同期スイートでは、すべての状態が var スコープになるようにします |
| テストの catch ブロックで例外を握りつぶす | 例外を伝播させ、toThrow() による検証に任せます |