codeception/module-doctrine2
Codeception module for Doctrine2 that gives tests direct access to the EntityManager and DBAL connection, automatic database cleanup between tests, transaction rollbacks, and handy assertions for entities and queries.
Install the module and enable it alongside the framework module it depends on:
composer require --dev codeception/module-doctrine2
# tests/functional.suite.yml (or api.suite.yml)
modules:
enabled:
- Symfony:
app_path: 'src'
environment: 'test'
- Doctrine2:
depends: Symfony # boots the EM from the Symfony test kernel
em: doctrine.orm.entity_manager
cleanup: true # wrap each test in a rolled-back transaction
For non-Symfony apps you usually pair it with a tiny helper that exposes the EM (see Implementation Patterns), because the module's job is mainly to provide $I->haveInRepository() / grabEntityFromRepository() and the transaction cleanup — it is not a fixture loader.
First things to read: the module source for haveInRepository(), seeInRepository(), _getEntityManager() and _before()/_after(); the cleanup flag lives there and is the whole reason your database stays clean.
Canonical first test: arrange an entity, hit an endpoint, assert the persisted state.
public function creatingAUserPersistsIt(ApiTester $I): void
{
$user = $I->haveInRepository(User::class, [
'email' => '[email protected]',
'password' => 'secret',
]);
$I->amBearerAuthenticated($user->getApiToken());
$I->sendPost('/api/projects', ['name' => 'Apollo']);
$I->seeResponseCodeIs(201);
$I->seeInRepository(Project::class, ['name' => 'Apollo', 'owner' => $user]);
}
Return the entity and reuse it. haveInRepository() returns the created object (in 3.x), which is the cleanest way to get IDs, tokens, or relation targets without raw SQL:
$org = $I->haveInRepository(Organization::class, ['name' => 'Acme']);
$user = $I->haveInRepository(User::class, ['email' => '[email protected]', 'organization' => $org]);
Passing an already-built entity as a field value works for associations and maps to the foreign key in both haveInRepository() and findOneBy()-style criteria.
Two-step persist/flush for object graphs. haveInRepository() flushes immediately; when you need to build a graph and flush once, use the lower-level pair:
$user = new User('[email protected]');
$I->persistEntity($user); // persist only
$I->persistEntity($profile);
$I->flushToDatabase(); // one flush
$I->flushToDatabase($someEntity); // or flush a specific entity
Use criteria, not raw SQL, for assertions. seeInRepository(), dontSeeInRepository(), grabEntityFromRepository() and grabEntitiesFromRepository() all accept field => value criteria, including association objects. For anything beyond equality (ranges, joins, aggregate counts), drop into the entity manager from a helper:
class DoctrineHelper extends \Codeception\Module
{
public function grabEntityManager(): \Doctrine\ORM\EntityManagerInterface
{
return $this->getModule('Doctrine2')->_getEntityManager();
}
public function grabActiveSubscriptions(\DateTimeImmutable $since): array
{
return $this->grabEntityManager()
->getRepository(Subscription::class)
->createQueryBuilder('s')
->where('s.startsAt > :since')
->setParameter('since', $since)
->getQuery()
->getResult();
}
}
Build a fixture DSL in tests/_support/Helper. The module gives you primitives, not a factory library. Wrap them so tests read as intent:
public function haveActiveUser(array $overrides = []): User
{
return $this->getModule('Doctrine2')->haveInRepository(
User::class,
array_merge(['state' => 'active', 'email' => uniqid('', true) . '@example.com'], $overrides)
);
}
Choose cleanup vs. fixtures deliberately. With cleanup: true (default), seed inside each test and let _after() roll back. For a read-mostly suite with a large shared dataset, set cleanup: false and load doctrine/data-fixtures once in _beforeSuite or _before, then truncate/reload on demand. Don't mix the two: shared fixtures will be invisible inside the cleanup transaction if they were inserted on a different connection.
Combine with REST/Symfony modules. Create the authenticated user with haveInRepository(), authenticate with the module's helper, exercise the route, then assert with seeInRepository(). This keeps HTTP-level tests honest — the assertion is against the database, not the response payload.
cleanup: true uses a transaction. Any CREATE TABLE/ALTER TABLE in a test (SchemaTool, migrations) issues an implicit commit in MySQL and breaks the rollback. Run schema setup in _beforeSuite, never inside a test. MyISAM tables also silently ignore the rollback.$I->grabEntityManager()->clear() (or a refresh helper) before asserting. The module clears on _after() but not mid-test.haveInRepository() bypasses the constructor (it instantiates without arguments and sets properties reflectively). That means default invariants, computed IDs, and constructor validation are skipped. Lifecycle callbacks such as prePersist do run, so a listener that depends on request context can fail in tests. Use real constructors/factories for entities with non-trivial invariants.persistEntity() without flushToDatabase() will make dontSeeInRepository() pass for the wrong reason. Always flush before asserting.grabEntityFromRepository() uses findOneBy() — equality only, no ordering, no joins. If you need "latest order for user", write a repository query in a helper rather than bending the module.dontSeeInRepository(User::class) asserts zero rows — handy as a sanity check, dangerous as a copy-paste.cleanup: true this can look like "no such table" in random tests; use a file-backed SQLite database or an explicit schema bootstrap per test.codeception/module-doctrine2: ^3; Codeception 4 needs ^2. The classic post-upgrade failure is ModuleConfigException from _getEntityManager() because depends/em no longer resolve.em config is a service ID, not a connection name. In multi-EM apps set it to the exact service (e.g. doctrine.orm.tenant_entity_manager) and keep one module per EM in the suite config.Doctrine\DBAL\Logging\DebugStack to the connection in a helper, or use the Symfony profiler in acceptance-style runs. Codeception's Db module logs SQL but is a separate connection and won't show the ORM's transaction._getEntityManager() is the intended escape hatch. Methods prefixed with _ are hidden from $I-> but callable from helpers, which is exactly where custom queries and $em->clear() belong.How can I help you explore Laravel packages today?