So you've inherited a Zend Framework 2 application. Maybe it's a billing system that's been compounding interest on its own complexity since 2014. Maybe it's an internal admin tool that three different teams have touched and then stopped touching. The original author left a comment somewhere that just says "// don't ask," and the README points to a Confluence page that no longer exists.
Your manager wants a small change. You open the controller, scroll down to find the action you need to edit, and there are three hundred lines of code that look like they were written by someone who really, really enjoyed nested if-statements. There are zero tests. There is no staging environment that mirrors prod. And the moment you change one line, you'll be the one who broke it.
This is the moment legacy testing actually matters. Not the kind of testing you read about on conference slides. The kind where you wrap a safety net around code you don't fully understand, so you can refactor without becoming a Slack ping at 3am.
ZF2 makes this awkward in its own way. The framework was officially renamed to Laminas in 2019, and active development moved there. But plenty of production apps never migrated. They sit on zendframework/zend-mvc, zendframework/zend-servicemanager, zendframework/zend-db, and the original Zend\Test\PHPUnit test utilities. The components are stable, the docs are old but mostly still online, and the testing story is genuinely solid once you know where to look. The trick is using three distinct kinds of tests, and knowing which one to reach for at which moment.
Why Three Kinds, Not One
A common reflex is to "just add unit tests." For a greenfield codebase, fine. For a legacy ZF2 app, that's the move that gets you a folder full of green tests that test nothing meaningful while the bug that took down prod last Tuesday goes uncaught.
The three layers that actually pay rent on a ZF2 codebase are:
A characterization test pins down what the code currently does, regardless of whether that behavior is correct. It's the safety net you write before you touch the code, so any unintended change shows up as a red test. Michael Feathers introduced the term in Working Effectively with Legacy Code, and it's the single most useful idea for inherited PHP work.
A controller test drives a real HTTP request through ZF2's MVC stack (router, dispatch, controller, view model) without you having to spin up a web server. ZF2 ships Zend\Test\PHPUnit\Controller\AbstractHttpControllerTestCase for exactly this. It's the layer where most of your business behavior lives in a typical ZF2 app, so it's where most of your test budget should go.
An integration test exercises a real slice of the system: usually a controller plus a service plus a database, with the production module config booted. No mocking the database. No stubbing the table gateway. The test confirms that the pieces, as wired in module.config.php, actually work together.
Three layers, three different jobs. Let's walk through them in the order you'd write them on a real legacy project.
Setting Up A PHPUnit Bootstrap That Boots The App
Before any of this works, you need PHPUnit to actually load your ZF2 application config in test mode. The convention is a Bootstrap.php file in tests/ that loads the autoloader and points to a test-specific application config:
<?php
namespace ApplicationTest;
use Zend\Loader\AutoloaderFactory;
use Zend\Mvc\Service\ServiceManagerConfig;
use Zend\ServiceManager\ServiceManager;
error_reporting(E_ALL | E_STRICT);
chdir(__DIR__ . '/../');
class Bootstrap
{
protected static $serviceManager;
protected static $config;
public static function init()
{
$zf2ModulePaths = [__DIR__ . '/../../../module', __DIR__ . '/../../../vendor'];
static::initAutoloader();
$config = require __DIR__ . '/TestConfig.php';
static::$config = $config;
$serviceManager = new ServiceManager(new ServiceManagerConfig());
$serviceManager->setService('ApplicationConfig', $config);
$serviceManager->get('ModuleManager')->loadModules();
static::$serviceManager = $serviceManager;
}
public static function getServiceManager()
{
return static::$serviceManager;
}
public static function getConfig()
{
return static::$config;
}
protected static function initAutoloader()
{
$vendorPath = __DIR__ . '/../../../vendor';
if (!file_exists($vendorPath . '/autoload.php')) {
throw new \RuntimeException('Run composer install before tests.');
}
include $vendorPath . '/autoload.php';
AutoloaderFactory::factory([
'Zend\Loader\StandardAutoloader' => [
'autoregister_zf' => true,
'namespaces' => [
__NAMESPACE__ => __DIR__ . '/' . __NAMESPACE__,
],
],
]);
}
}
Bootstrap::init();
The companion TestConfig.php is a stripped-down version of your application.config.php. Same modules loaded, but pointing at a test database, with caching disabled, and any "production-only" listeners (mail senders, payment gateways) replaced by stubs:
<?php
return [
'modules' => [
'Application',
'Billing',
'Catalog',
'User',
// Don't load production-only modules here.
],
'module_listener_options' => [
'config_glob_paths' => ['config/autoload/{,*.}{global,local.test}.php'],
'module_paths' => ['./module', './vendor'],
'config_cache_enabled' => false,
'module_map_cache_enabled' => false,
],
];
The local.test.php file is the magic. ZF2's config merge order means anything in config/autoload/local.test.php overrides values in local.php and global.php when those globs match. So a db config entry in local.test.php can point at a SQLite file or a :memory: database for the duration of the test run, without touching the production config at all.
<?php
return [
'db' => [
'driver' => 'Pdo_Sqlite',
'dsn' => 'sqlite::memory:',
],
'service_manager' => [
'factories' => [
'Zend\Db\Adapter\Adapter' => 'Zend\Db\Adapter\AdapterServiceFactory',
],
],
];
Set up once, this lets every test boot the same module manager, the same service manager, the same routes, and the same listeners that production does. Just with the dangerous parts swapped out. That's the foundation. Everything else is just deciding what shape of test to write on top of it.
Characterization Tests: Pinning Down What's Already There
Here's the bit that feels counterintuitive at first. A characterization test doesn't assert that the code is correct. It asserts that the code is exactly the way it is right now. The whole point is to be honest about the fact that you don't yet know what the code is supposed to do, only what it currently does, and to lock that in before you change anything.
Imagine a free-floating helper buried somewhere in a legacy module:
public function calculateTotal(array $lineItems, $customer, $promoCode = null)
{
$subtotal = 0;
foreach ($lineItems as $item) {
$subtotal += $item['quantity'] * $item['unit_price'];
}
if ($customer['tier'] === 'gold') {
$subtotal *= 0.9;
} elseif ($customer['tier'] === 'platinum') {
$subtotal *= 0.85;
}
if ($promoCode === 'SUMMER10') {
$subtotal -= 10;
}
$tax = $subtotal * 0.2;
return round($subtotal + $tax, 2);
}
You don't know if the order of discounts is right. You don't know if round() should be floor(). You don't know if the tax should compound on the discount or the original. You suspect at least one of these is wrong, but you also know that some customer is currently relying on whatever this returns, and changing it silently will cause refunds.
A characterization test approach: feed it inputs, observe the outputs, and pin those outputs in as assertions. Don't argue with the numbers. Record them.
<?php
namespace BillingTest\Service;
use Billing\Service\InvoiceCalculator;
use PHPUnit_Framework_TestCase;
class InvoiceCalculatorCharacterizationTest extends PHPUnit_Framework_TestCase
{
/**
* @dataProvider currentBehaviour
*/
public function testItProducesTheSameTotalAsToday(
array $items,
array $customer,
$promo,
$expectedTotal
) {
$calculator = new InvoiceCalculator();
$actual = $calculator->calculateTotal($items, $customer, $promo);
$this->assertSame($expectedTotal, $actual);
}
public function currentBehaviour()
{
return [
'standard customer, no promo' => [
[['quantity' => 2, 'unit_price' => 50.00]],
['tier' => 'standard'],
null,
120.00, // 100 + 20 tax
],
'gold customer, no promo' => [
[['quantity' => 2, 'unit_price' => 50.00]],
['tier' => 'gold'],
null,
108.00, // 90 + 18 tax
],
'gold customer with summer promo' => [
[['quantity' => 2, 'unit_price' => 50.00]],
['tier' => 'gold'],
'SUMMER10',
96.00, // 80 + 16 tax
],
'empty items, platinum tier' => [
[],
['tier' => 'platinum'],
null,
0.00,
],
];
}
}
Read those data-provider names. "gold customer with summer promo, 96.00." That's the value the system currently returns. It might be wrong. The test says nothing about correctness. It says only that today, on August 22, 2020, the function returns 96.00 for that input.
Now you can refactor. Rename variables. Extract methods. Push the tax logic into a TaxCalculator. Push the discount logic into a DiscountResolver. Every refactoring step runs the suite. If the suite stays green, you haven't changed observable behavior. If a test goes red, you've found one of two things: a real bug you've now exposed, or a behavior you didn't realize was load-bearing.
The discipline that makes this work is not editing the captured values to be "what they should be." If you spot what looks like a bug in the captured output, a tax that's compounding wrong, a discount that's applied in the wrong order, leave the test as it is, file a bug, and decide separately whether to fix it. The test's job is to lock down the current behavior. The fix is a separate decision, with its own test that changes the captured value on purpose.
Controller Tests With AbstractHttpControllerTestCase
Once a slice of business logic has characterization tests around it, you can start moving up the stack. The next layer is the controller. The place in a ZF2 app where most of the conditional logic, the validation, the redirect-vs-render decisions actually live.
ZF2 ships a base class designed exactly for this: Zend\Test\PHPUnit\Controller\AbstractHttpControllerTestCase. It boots the full ZF2 application, builds a real request, dispatches it through your routes, and gives you assertions for status codes, route names, controller classes, response content, and redirect targets.
A skeleton:
<?php
namespace BillingTest\Controller;
use ApplicationTest\Bootstrap;
use Zend\Test\PHPUnit\Controller\AbstractHttpControllerTestCase;
class InvoiceControllerTest extends AbstractHttpControllerTestCase
{
public function setUp()
{
$this->setApplicationConfig(Bootstrap::getConfig());
parent::setUp();
}
public function testInvoiceListPageReturnsOk()
{
$this->dispatch('/billing/invoices', 'GET');
$this->assertResponseStatusCode(200);
$this->assertModuleName('Billing');
$this->assertControllerName('Billing\Controller\Invoice');
$this->assertControllerClass('InvoiceController');
$this->assertMatchedRouteName('billing/invoices');
}
}
setApplicationConfig() is the hook that tells the test which application config to boot. That's where the test-mode SQLite, the test-mode service manager overrides, and the test-mode module list all come from. dispatch() builds a request, fires it through the MVC event loop, and stores the response so the assertions can inspect it. And the four assert*Name() calls are ZF2-specific niceties. They let you verify that the request landed where you think it did, not just that some controller returned a 200.
That last point matters more than it looks. In a legacy ZF2 app with a hundred routes, it's easy to write a controller test that passes by accident because the URL you dispatched matched a different route than you expected. assertMatchedRouteName() makes that mistake loud.
For testing form posts, the same dispatch() call takes a third argument with the request parameters:
public function testItCreatesAnInvoice()
{
$this->dispatch('/billing/invoices/new', 'POST', [
'customer_id' => 42,
'amount' => 12500,
'currency' => 'USD',
'due_date' => '2020-10-15',
]);
$this->assertResponseStatusCode(302);
$this->assertRedirectTo('/billing/invoices');
}
public function testItRejectsAnInvoiceWithMissingFields()
{
$this->dispatch('/billing/invoices/new', 'POST', [
'customer_id' => 42,
]);
$this->assertResponseStatusCode(200); // form re-renders, not a redirect
$this->assertQueryContentContains('.error', 'Amount is required');
}
assertQueryContentContains() is one of the more useful sleeper features of the ZF2 test toolkit. It runs a CSS query against the rendered HTML and checks that the result contains the given string. So you can test that the form re-rendered with the expected validation message without parsing the HTML yourself.
The full assertion surface is worth memorising for legacy work:
assertResponseStatusCode($code): the HTTP status of the response.
assertModuleName($module): the module the matched route belongs to.
assertControllerName($name): the service-manager name of the controller.
assertControllerClass($shortClassName): the short class name (just the class, no namespace).
assertActionName($action): the action method that was invoked.
assertMatchedRouteName($route): the route name from module.config.php.
assertRedirect(): that the response is a 3xx redirect.
assertRedirectTo($url): that the redirect points at a specific URL.
assertQuery($cssSelector): that the rendered HTML contains a node matching the selector.
assertQueryContentContains($cssSelector, $text): same but with substring matching.
assertNotRedirect(): for cases where you specifically don't want a redirect (form re-render).
Notice what's not there: there's no mocking framework baked in. You're free to use PHPUnit's built-in mocks, or Mockery, or Prophecy. The controller test class only owns the request/response/route surface.

Sidestepping The Service Manager Trap
One thing that bites every team new to controller-testing ZF2 is that the service manager is a singleton-ish thing during a test run. If your controller depends on a Billing\Service\InvoiceCalculator and you want to substitute a fake one for a particular test, you need to override it in the service manager after the application has booted but before the dispatch happens.
The pattern:
public function testItHandlesACalculatorFailure()
{
$brokenCalculator = $this->createMock(\Billing\Service\InvoiceCalculator::class);
$brokenCalculator->method('calculateTotal')
->willThrowException(new \RuntimeException('upstream down'));
$services = $this->getApplicationServiceLocator();
$services->setAllowOverride(true);
$services->setService(\Billing\Service\InvoiceCalculator::class, $brokenCalculator);
$this->dispatch('/billing/invoices/new', 'POST', $this->validPayload());
$this->assertResponseStatusCode(500);
}
getApplicationServiceLocator() returns the booted service manager for the current test. setAllowOverride(true) is the line people forget. Without it, ZF2 refuses to replace an already-registered service and you get a confusing exception. With it on, you can swap in a mock for the duration of the test.
The setUp() of the base test case runs before every test, which means a fresh service manager is built each time. So the override stays scoped, and you don't have to manually undo it.
Integration Tests: The Whole Thing, For Real
Controller tests with mocked services are fast and tight. They tell you that the controller does the right thing given a working service. They do not tell you that the service, the table gateway, the database schema, and the module config actually fit together. That's what an integration test is for.
The way ZF2 makes this practical is that your Bootstrap::init() already loaded the module manager, which loaded every module's Module.php, which registered the real factories, which means by the time the test runs, the service manager already knows how to build a real InvoiceCalculator with its real TableGateway dependency against the test SQLite database. You don't have to wire anything up. You just have to write the test in a way that exercises the real chain instead of substituting in mocks.
<?php
namespace BillingTest\Integration;
use ApplicationTest\Bootstrap;
use Zend\Db\Adapter\Adapter;
use Zend\Test\PHPUnit\Controller\AbstractHttpControllerTestCase;
class InvoiceFlowTest extends AbstractHttpControllerTestCase
{
private $adapter;
public function setUp()
{
$this->setApplicationConfig(Bootstrap::getConfig());
parent::setUp();
$this->adapter = $this->getApplicationServiceLocator()
->get(Adapter::class);
$this->runSchema();
$this->seedReferenceData();
}
public function testItCreatesAnInvoiceAndPersistsItToTheDatabase()
{
$this->dispatch('/billing/invoices/new', 'POST', [
'customer_id' => 42,
'amount' => 12500,
'currency' => 'USD',
'due_date' => '2020-10-15',
]);
$this->assertResponseStatusCode(302);
$row = $this->adapter
->query('SELECT * FROM invoices WHERE customer_id = ?',
[42])
->current();
$this->assertNotNull($row);
$this->assertSame('12500', (string) $row->amount);
$this->assertSame('USD', $row->currency);
$this->assertSame('open', $row->status);
}
private function runSchema()
{
$schema = file_get_contents(__DIR__ . '/../../../sql/schema.sql');
foreach (explode(';', $schema) as $statement) {
$statement = trim($statement);
if ($statement !== '') {
$this->adapter->query($statement, Adapter::QUERY_MODE_EXECUTE);
}
}
}
private function seedReferenceData()
{
$this->adapter->query(
"INSERT INTO customers (id, tier) VALUES (42, 'gold')",
Adapter::QUERY_MODE_EXECUTE
);
}
}
This is the test that catches the bugs the other layers miss. A migration that's drifted from the entity definition. A factory that's wired up the wrong constructor argument. A Zend\Hydrator that's mapping due_date to a property that doesn't exist. A TableGateway that's silently writing to the wrong table because someone renamed the schema without updating the config.
Integration tests are slower than the others by a comfortable margin. Each one boots the whole app and runs real SQL. So they're not where most of your assertions live. The shape that tends to work for a typical ZF2 service is something like a hundred characterization and unit tests at the bottom, fifty controller tests in the middle, and ten to twenty integration tests at the top that exercise the critical flows end-to-end.
Resetting Between Tests Without Re-Booting Everything
Each test that uses the in-memory SQLite gets a fresh database, but each one also pays the cost of running schema.sql and reseeding reference data in setUp(). For a small schema, this is fine. For a hundred-table legacy schema, the boot time creeps up.
Two patterns that help:
The simpler one is to share the schema-creation step across the test class with a setUpBeforeClass() hook and use transactions for per-test isolation. SQLite's :memory: database is per-connection though, so this only works cleanly when every test uses the same long-lived adapter, which is what ZF2's default service manager already does, since Zend\Db\Adapter\Adapter is registered as a shared service.
The other one, for projects with mountainous schemas, is to keep a templated SQLite file on disk and copy it for each test. cp tests/fixtures/schema.sqlite tests/var/run.sqlite is faster than re-running a hundred CREATE TABLE statements, and it gives you a deterministic starting point you can inspect by hand if a test fails mysteriously.
Neither pattern is magic. The point is that the boot cost is real, it scales with your schema, and the only way to keep your integration suite usable is to be deliberate about it from the start.
The Order You'd Actually Do This On A Real Project
If you've just inherited the ZF2 app and you're staring at zero tests, the order that's saved me the most grey hair:
Start with characterization tests around the smallest, most-feared piece of business logic. The function someone has commented "// magic, don't touch" above. Write fifty data-provider rows. Capture the outputs. Don't change anything yet. You now have a safety net for one function. The codebase didn't get safer overall, but that one function did.
Next, add controller tests around the route someone is asking you to change. One happy-path test, three or four sad-path tests. Use real service manager bindings where you can, mock only the pieces that talk to external systems (payment gateways, email, S3). This is where the bulk of your test budget is going to end up living.
Finally, write a small handful of integration tests that exercise the flow end-to-end. Order placement, invoice generation, the user signup flow, whatever the business actually depends on. These are the ones you'd want running before a release branch is cut, not on every commit. They're slow but high-value.
And one thing that's worth repeating because it's the discipline most legacy work fails on: resist the urge to "fix" the captured behavior while you're writing characterization tests. Yes, the tax calculation looks weird. Yes, the discount order seems backwards. Yes, the if ($customer['tier'] === 'gold') should probably be a polymorphic dispatch and not a string match. Those are real concerns. They're also a separate decision, with their own tests, made deliberately, not as a side effect of the test-writing pass. Characterization first, refactor second, fix third. In that order.
ZF2 is going to be in production somewhere for years to come. The codebase you're holding might outlive the framework's official support window. A good test harness is what keeps it from becoming the kind of code that nobody dares deploy on a Friday, and the three layers above are how you build it.





