From 756d91fddade29f71731c5b5f7ae802faa7eccd5 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 28 Aug 2026 13:07:45 -0600 Subject: [PATCH] feat: add model factories for testing --- README.md | 48 +++++ box.json | 6 +- resources/testing/Factory.cfc | 99 ++++++++++ resources/testing/FactoryBuilder.cfc | 216 ++++++++++++++++++++++ resources/testing/FactoryManager.cfc | 42 +++++ resources/testing/Sequence.cfc | 29 +++ tests/resources/factories/UserFactory.cfc | 27 +++ tests/specs/integration/FactorySpec.cfc | 118 ++++++++++++ 8 files changed, 582 insertions(+), 3 deletions(-) create mode 100644 resources/testing/Factory.cfc create mode 100644 resources/testing/FactoryBuilder.cfc create mode 100644 resources/testing/FactoryManager.cfc create mode 100644 resources/testing/Sequence.cfc create mode 100644 tests/resources/factories/UserFactory.cfc create mode 100644 tests/specs/integration/FactorySpec.cfc diff --git a/README.md b/README.md index c6e6edf4..f167b2e2 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,54 @@ component extends="quick.models.BaseEntity" { Query caching stores database results, not live Quick entities or loaded relationships. Cache lifetime and invalidation are managed by the CFML engine, so use short lifetimes for data that Quick or another process may update. For application-specific invalidation or distributed caching, cache entity mementos in CacheBox at the service layer and rehydrate them through Quick's public APIs. +### Testing with model factories + +Quick includes Laravel-inspired model factories under `quick.resources.testing`. Define application factories outside of your production model code: + +```javascript +// tests/resources/factories/UserFactory.cfc +component extends="quick.resources.testing.Factory" { + + struct function definition() { + return { + username : "factory-#lCase( createUUID() )#", + firstName : "Factory", + lastName : "User" + }; + } + + any function administrator() { + return state( { type : "admin" } ); + } + +} +``` + +Create a manager in your test base class and expose a short `factory()` helper: + +```javascript +variables.factoryManager = new quick.resources.testing.FactoryManager( + wirebox = getWireBox(), + factoryPath = "tests.resources.factories" +); + +any function factory( required string name ) { + return variables.factoryManager.factory( arguments.name ); +} +``` + +Factories support default definitions, explicit and named states, counts, sequences, attribute closures, and `afterMaking` and `afterCreating` callbacks. `make()` returns unsaved Quick entities, while `create()` persists through the entity's normal `save()` lifecycle: + +```javascript +var admin = factory( "User" ).administrator().create(); +var users = factory( "User" ).count( 3 ).create(); +var unsavedUser = factory( "User" ).make( { firstName : "Override" } ); +``` + +Factories do not manage database transactions. Integration tests should start a transaction around each test and roll it back in `finally`, ensuring both passing and failing tests leave the database unchanged. + +All factory implementation classes are isolated beneath `resources/testing`; production deployment tooling may exclude that directory. Quick does not load or register these classes during normal module startup. + ### Tests and Contributing To run the tests, first clone this repo and run a `box install`. diff --git a/box.json b/box.json index 05e6a673..9996dd8e 100644 --- a/box.json +++ b/box.json @@ -20,9 +20,9 @@ "shortDescription":"A ColdBox ORM Engine", "description":"A ColdBox ORM Engine", "scripts":{ - "format":"cfformat run dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,tests/specs/**/*.cfc --overwrite", - "format:check":"cfformat check dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,tests/specs/**/*.cfc --verbose", - "format:watch":"cfformat watch dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,tests/specs/**/*.cfc", + "format":"cfformat run dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,resources/testing/**/*.cfc,tests/resources/factories/**/*.cfc,tests/specs/**/*.cfc --overwrite", + "format:check":"cfformat check dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,resources/testing/**/*.cfc,tests/resources/factories/**/*.cfc,tests/specs/**/*.cfc --verbose", + "format:watch":"cfformat watch dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,resources/testing/**/*.cfc,tests/resources/factories/**/*.cfc,tests/specs/**/*.cfc", "generateAPIDocs":"rm .tmp --recurse --force && docbox generate mapping=quick excludes=test|/modules|ModuleConfig|QuickCollection strategy-outputDir=.tmp/apidocs strategy-projectTitle=Quick", "install:2021":"cfpm install document,feed,mysql,zip", "bx-modules:install":"install bx-compat-cfml@be,bx-esapi,bx-mysql" diff --git a/resources/testing/Factory.cfc b/resources/testing/Factory.cfc new file mode 100644 index 00000000..ae53e201 --- /dev/null +++ b/resources/testing/Factory.cfc @@ -0,0 +1,99 @@ +/** + * Base class for Laravel-inspired Quick model factories. + * + * Factory support lives under `resources/testing` so applications can omit the + * entire directory from production deployments. Subclasses provide + * `definition()` and may expose named states which return `state( ... )`. + */ +component { + + /** + * Create a factory for a Quick entity provider. + * + * @entityProvider A WireBox provider for the Quick entity mapping. + * @context Optional application-specific values available to definitions and states. + */ + public any function init( required any entityProvider, struct context = {} ) { + variables.entityProvider = arguments.entityProvider; + variables.factoryContext = arguments.context; + variables.afterMakingCallbacks = []; + variables.afterCreatingCallbacks = []; + configure(); + return this; + } + + /** + * Return the default attributes for one entity. + */ + public struct function definition() { + throw( type = "QuickFactory.AbstractMethod", message = "Factory subclasses must implement definition()." ); + } + + /** + * Register factory-wide callbacks in subclasses. + */ + public any function configure() { + return this; + } + + public any function state( required any transformation ) { + return newBuilder().state( arguments.transformation ); + } + + public any function sequence( required array states ) { + return newBuilder().sequence( arguments.states ); + } + + public any function count( required numeric amount ) { + return newBuilder().count( arguments.amount ); + } + + public any function make( struct attributes = {} ) { + return newBuilder().make( arguments.attributes ); + } + + public any function create( struct attributes = {} ) { + return newBuilder().create( arguments.attributes ); + } + + public any function afterMaking( required any callback ) { + if ( !isCallable( arguments.callback ) ) { + throw( type = "QuickFactory.InvalidCallback", message = "Factory callbacks must be closures or functions." ); + } + arrayAppend( variables.afterMakingCallbacks, arguments.callback ); + return this; + } + + public any function afterCreating( required any callback ) { + if ( !isCallable( arguments.callback ) ) { + throw( type = "QuickFactory.InvalidCallback", message = "Factory callbacks must be closures or functions." ); + } + arrayAppend( variables.afterCreatingCallbacks, arguments.callback ); + return this; + } + + public struct function getFactoryContext() { + return variables.factoryContext; + } + + public any function newEntity( required struct attributes ) { + return variables.entityProvider.newEntity().fill( arguments.attributes ); + } + + public array function getAfterMakingCallbacks() { + return variables.afterMakingCallbacks; + } + + public array function getAfterCreatingCallbacks() { + return variables.afterCreatingCallbacks; + } + + private any function newBuilder() { + return new quick.resources.testing.FactoryBuilder( this ); + } + + private boolean function isCallable( required any candidate ) { + return isClosure( arguments.candidate ) || isCustomFunction( arguments.candidate ); + } + +} diff --git a/resources/testing/FactoryBuilder.cfc b/resources/testing/FactoryBuilder.cfc new file mode 100644 index 00000000..9e9bee02 --- /dev/null +++ b/resources/testing/FactoryBuilder.cfc @@ -0,0 +1,216 @@ +/** + * A one-use fluent builder produced by a Quick factory definition. + */ +component { + + public any function init( required any factory ) { + variables.factory = arguments.factory; + variables.amount = 1; + variables.explicitCount = false; + variables.transformations = []; + variables.afterMakingCallbacks = []; + variables.afterCreatingCallbacks = []; + return this; + } + + public any function count( required numeric amount ) { + if ( arguments.amount < 0 || int( arguments.amount ) != arguments.amount ) { + throw( type = "QuickFactory.InvalidCount", message = "Factory count must be a non-negative integer." ); + } + variables.amount = int( arguments.amount ); + variables.explicitCount = true; + return this; + } + + public any function state( required any transformation ) { + if ( !isStruct( arguments.transformation ) && !isCallable( arguments.transformation ) ) { + throw( type = "QuickFactory.InvalidState", message = "Factory state must be a struct or closure." ); + } + arrayAppend( variables.transformations, arguments.transformation ); + return this; + } + + public any function sequence( required array states ) { + arrayAppend( variables.transformations, new quick.resources.testing.Sequence( arguments.states ) ); + return this; + } + + public any function afterMaking( required any callback ) { + if ( !isCallable( arguments.callback ) ) { + throw( type = "QuickFactory.InvalidCallback", message = "Factory callbacks must be closures or functions." ); + } + arrayAppend( variables.afterMakingCallbacks, arguments.callback ); + return this; + } + + public any function afterCreating( required any callback ) { + if ( !isCallable( arguments.callback ) ) { + throw( type = "QuickFactory.InvalidCallback", message = "Factory callbacks must be closures or functions." ); + } + arrayAppend( variables.afterCreatingCallbacks, arguments.callback ); + return this; + } + + /** + * Forward named state methods to the factory definition so chains may use + * either `factory.count( 3 ).inactive()` or `factory.inactive().count( 3 )`. + */ + public any function onMissingMethod( required string missingMethodName, required struct missingMethodArguments ) { + if ( !structKeyExists( variables.factory, arguments.missingMethodName ) ) { + throw( + type = "QuickFactory.UnknownMethod", + message = "Unknown factory method [#arguments.missingMethodName#]." + ); + } + var stateBuilder = invoke( + variables.factory, + arguments.missingMethodName, + arguments.missingMethodArguments + ); + if ( !isInstanceOf( stateBuilder, "quick.resources.testing.FactoryBuilder" ) ) { + return stateBuilder; + } + arrayAppend( + variables.transformations, + stateBuilder.getTransformations(), + true + ); + arrayAppend( + variables.afterMakingCallbacks, + stateBuilder.getAfterMakingCallbacks(), + true + ); + arrayAppend( + variables.afterCreatingCallbacks, + stateBuilder.getAfterCreatingCallbacks(), + true + ); + return this; + } + + public array function getTransformations() { + return variables.transformations; + } + + public array function getAfterMakingCallbacks() { + return variables.afterMakingCallbacks; + } + + public array function getAfterCreatingCallbacks() { + return variables.afterCreatingCallbacks; + } + + /** + * Build Quick entities without persisting them. + */ + public any function make( struct attributes = {} ) { + var entities = []; + for ( var index = 1; index <= variables.amount; index++ ) { + var evaluatedAttributes = evaluateAttributes( + attributes = arguments.attributes, + index = index, + count = variables.amount + ); + var entity = variables.factory.newEntity( evaluatedAttributes ); + runCallbacks( + variables.factory.getAfterMakingCallbacks(), + entity, + evaluatedAttributes + ); + runCallbacks( + variables.afterMakingCallbacks, + entity, + evaluatedAttributes + ); + arrayAppend( entities, entity ); + } + return variables.explicitCount ? entities : entities[ 1 ]; + } + + /** + * Build and persist Quick entities through `BaseEntity.save()`. + */ + public any function create( struct attributes = {} ) { + var entities = make( arguments.attributes ); + var collection = variables.explicitCount ? entities : [ entities ]; + for ( var entity in collection ) { + entity.save(); + var persistedAttributes = entity.retrieveAttributesData(); + runCallbacks( + variables.factory.getAfterCreatingCallbacks(), + entity, + persistedAttributes + ); + runCallbacks( + variables.afterCreatingCallbacks, + entity, + persistedAttributes + ); + } + return variables.explicitCount ? collection : collection[ 1 ]; + } + + private struct function evaluateAttributes( + required struct attributes, + required numeric index, + required numeric count + ) { + var definition = variables.factory.definition(); + if ( !isStruct( definition ) ) { + throw( type = "QuickFactory.InvalidDefinition", message = "Factory definitions must return a struct." ); + } + + var values = copyStruct( definition ); + var context = { + index : arguments.index - 1, + count : arguments.count + }; + + for ( var transformation in variables.transformations ) { + var changes = {}; + if ( isInstanceOf( transformation, "quick.resources.testing.Sequence" ) ) { + changes = transformation.next( copyStruct( values ), context ); + } else if ( isStruct( transformation ) ) { + changes = transformation; + } else if ( isCallable( transformation ) ) { + changes = transformation( copyStruct( values ), context ); + } + if ( !isStruct( changes ) ) { + throw( + type = "QuickFactory.InvalidStateResult", + message = "Factory state transformations must return a struct." + ); + } + structAppend( values, changes, true ); + } + + structAppend( values, arguments.attributes, true ); + for ( var key in values ) { + if ( !isNull( values[ key ] ) && isCallable( values[ key ] ) ) { + values[ key ] = values[ key ]( copyStruct( values ), context ); + } + } + return values; + } + + private void function runCallbacks( + required array callbacks, + required any entity, + required struct attributes + ) { + for ( var callback in arguments.callbacks ) { + callback( arguments.entity, arguments.attributes ); + } + } + + private struct function copyStruct( required struct source ) { + var copied = {}; + structAppend( copied, arguments.source, true ); + return copied; + } + + private boolean function isCallable( required any candidate ) { + return isClosure( arguments.candidate ) || isCustomFunction( arguments.candidate ); + } + +} diff --git a/resources/testing/FactoryManager.cfc b/resources/testing/FactoryManager.cfc new file mode 100644 index 00000000..18f7a6aa --- /dev/null +++ b/resources/testing/FactoryManager.cfc @@ -0,0 +1,42 @@ +/** + * Lazily discovers application factory definitions by convention. + * + * A request for `User` resolves `.UserFactory` through WireBox and + * supplies the matching Quick entity provider plus the per-test context. + */ +component { + + public any function init( + required any wirebox, + required string factoryPath, + struct context = {} + ) { + variables.wirebox = arguments.wirebox; + variables.factoryPath = arguments.factoryPath; + variables.context = arguments.context; + variables.factories = {}; + return this; + } + + public any function factory( required string name ) { + if ( !reFind( "^[A-Za-z][A-Za-z0-9]*$", arguments.name ) ) { + throw( type = "QuickFactory.InvalidFactoryName", message = "Invalid factory name [#arguments.name#]." ); + } + + if ( !structKeyExists( variables.factories, arguments.name ) ) { + variables.factories[ arguments.name ] = variables.wirebox.getInstance( + name = "#variables.factoryPath#.#arguments.name#Factory", + initArguments = { + entityProvider : variables.wirebox.getInstance( + dsl = "provider:#arguments.name#", + targetObject = this + ), + context : variables.context + } + ); + } + + return variables.factories[ arguments.name ]; + } + +} diff --git a/resources/testing/Sequence.cfc b/resources/testing/Sequence.cfc new file mode 100644 index 00000000..6443d19a --- /dev/null +++ b/resources/testing/Sequence.cfc @@ -0,0 +1,29 @@ +/** + * Cycles factory state values across a counted make/create operation. + */ +component { + + public any function init( required array states ) { + if ( arrayLen( arguments.states ) == 0 ) { + throw( type = "QuickFactory.EmptySequence", message = "Factory sequences require at least one state." ); + } + variables.states = arguments.states; + return this; + } + + public struct function next( required struct attributes, required struct context ) { + var position = ( arguments.context.index mod arrayLen( variables.states ) ) + 1; + var value = variables.states[ position ]; + if ( !isNull( value ) && ( isClosure( value ) || isCustomFunction( value ) ) ) { + value = value( arguments.attributes, arguments.context ); + } + if ( isNull( value ) || !isStruct( value ) ) { + throw( + type = "QuickFactory.InvalidSequenceState", + message = "Each factory sequence value must be a struct or closure returning a struct." + ); + } + return value; + } + +} diff --git a/tests/resources/factories/UserFactory.cfc b/tests/resources/factories/UserFactory.cfc new file mode 100644 index 00000000..91024b51 --- /dev/null +++ b/tests/resources/factories/UserFactory.cfc @@ -0,0 +1,27 @@ +component extends="quick.resources.testing.Factory" { + + property name="wirebox" inject="wirebox"; + + struct function definition() { + var suffix = structKeyExists( getFactoryContext(), "suffix" ) ? getFactoryContext().suffix : "default"; + return { + username : "factory-#suffix#-#lCase( createUUID() )#", + firstName : "Factory", + lastName : function( attributes, context ) { + return "User #context.index#"; + }, + email : "factory-#lCase( createUUID() )#@example.test", + password : hash( "password" ), + type : "limited" + }; + } + + any function administrator() { + return state( { type : "admin" } ); + } + + any function wired() { + return state( { firstName : isObject( variables.wirebox ) ? "Injected" : "Missing" } ); + } + +} diff --git a/tests/specs/integration/FactorySpec.cfc b/tests/specs/integration/FactorySpec.cfc new file mode 100644 index 00000000..fd9c30d5 --- /dev/null +++ b/tests/specs/integration/FactorySpec.cfc @@ -0,0 +1,118 @@ +component extends="tests.resources.ModuleIntegrationSpec" { + + function run() { + describe( "Quick model factories", function() { + it( "makes unsaved entities from defaults and explicit overrides", function() { + var user = newFactoryManager( { suffix : "make" } ) + .factory( "User" ) + .make( { firstName : "Overridden" } ); + + expect( user ).toBeInstanceOf( "User" ); + expect( user.isLoaded() ).toBeFalse(); + expect( user.getUsername() ).toInclude( "factory-make-" ); + expect( user.getFirstName() ).toBe( "Overridden" ); + expect( user.getLastName() ).toBe( "User 0" ); + expect( getInstance( "User" ).where( "username", user.getUsername() ).count() ).toBe( 0 ); + } ); + + it( "combines counts, named states, sequences, and persisted Quick entities", function() { + var users = newFactoryManager( { suffix : "sequence" } ) + .factory( "User" ) + .count( 3 ) + .administrator() + .state( function( attributes, context ) { + return { firstName : "State #context.index#" }; + } ) + .sequence( [ + { lastName : "Sequence A" }, + function( attributes, context ) { + return { lastName : "Sequence #context.index#" }; + } + ] ) + .create(); + + expect( users ).toHaveLength( 3 ); + expect( users[ 1 ].getLastName() ).toBe( "Sequence A" ); + expect( users[ 2 ].getLastName() ).toBe( "Sequence 1" ); + expect( users[ 3 ].getLastName() ).toBe( "Sequence A" ); + expect( users[ 2 ].getFirstName() ).toBe( "State 1" ); + expect( users[ 1 ].getType() ).toBe( "admin" ); + expect( users[ 1 ].isLoaded() ).toBeTrue(); + expect( getInstance( "User" ).whereLike( "username", "factory-sequence-%" ).count() ).toBe( 3 ); + } ); + + it( "creates factories through WireBox so application dependencies are injected", function() { + var user = newFactoryManager() + .factory( "User" ) + .wired() + .make(); + + expect( user.getFirstName() ).toBe( "Injected" ); + } ); + + it( "runs one-use after-making and after-creating callbacks", function() { + var made = []; + var created = []; + var user = newFactoryManager() + .factory( "User" ) + .state( { username : "factory-callback" } ) + .afterMaking( function( entity, attributes ) { + arrayAppend( made, attributes.username ); + } ) + .afterCreating( function( entity, attributes ) { + arrayAppend( created, attributes.id ); + } ) + .create(); + + expect( made ).toBe( [ "factory-callback" ] ); + expect( created ).toHaveLength( 1 ); + expect( created[ 1 ] ).toBe( user.getId() ); + } ); + + it( "returns arrays whenever count is explicit", function() { + var one = newFactoryManager() + .factory( "User" ) + .count( 1 ) + .make(); + var none = newFactoryManager() + .factory( "User" ) + .count( 0 ) + .make(); + + expect( one ).toBeArray(); + expect( one ).toHaveLength( 1 ); + expect( none ).toBeArray(); + expect( none ).toBeEmpty(); + } ); + + it( "rejects invalid counts, states, sequences, callbacks, and factory names", function() { + var factory = newFactoryManager().factory( "User" ); + + expect( function() { + factory.count( -1 ); + } ).toThrow( type = "QuickFactory.InvalidCount" ); + expect( function() { + factory.state( "invalid" ); + } ).toThrow( type = "QuickFactory.InvalidState" ); + expect( function() { + factory.sequence( [] ); + } ).toThrow( type = "QuickFactory.EmptySequence" ); + expect( function() { + factory.afterCreating( "invalid" ); + } ).toThrow( type = "QuickFactory.InvalidCallback" ); + expect( function() { + newFactoryManager().factory( "User;drop" ); + } ).toThrow( type = "QuickFactory.InvalidFactoryName" ); + } ); + } ); + } + + private any function newFactoryManager( struct context = {} ) { + return new quick.resources.testing.FactoryManager( + wirebox = getWireBox(), + factoryPath = "tests.resources.factories", + context = arguments.context + ); + } + +}