Integration tests¶
This module ships a real integration test pipeline: it boots the full Magento
application (Open Source or Commerce) against a working MariaDB + OpenSearch +
Redis stack, registers Taxcloud_Magento2 into app/code/, and runs PHPUnit
against a small custom bootstrap that loads the same installed Magento. We
do not wrap Magento's dev/tests/integration/ framework — that's for
testing Magento core, and brings far more machinery than a single module
needs.
The unit suite (
Test/Unit/) is the fast feedback loop and runs on every PR. The integration suite documented here is expensive, manual, and exists to gate big releases. It does not run on push or pull requests.
Getting started locally (5 minutes)¶
Prerequisites¶
- Docker Desktop (or Docker Engine 24+ on Linux) with Compose v2
- GNU Make
- ~5 GB free disk for the Magento install and Docker volumes
- A TaxCloud sandbox account — free, sign up at https://taxcloud.com/sandbox. Grab the API ID + key from the dashboard.
- Magento Marketplace public/private keys — free, sign up at
https://commercemarketplace.adobe.com/customer/accessKeys/. Required for
both editions because Composer pulls Magento from
repo.magento.com.
Steps¶
# from the module root (app/code/Taxcloud/Magento2)
cp .env.example .env
$EDITOR .env # fill in the four credentials above
make integration-test
That's it. On the first run, the install script will:
- Bring up the integration stack via
docker-compose.yml. composer create-project magento/project-community-edition=<version>into a sibling directory (../magento-community-<version>by default).- Symlink this module into the install's
app/code/Taxcloud/Magento2. - Run
bin/magento setup:installfor a clean baseline install. - Run
setup:upgrade,setup:di:compile, enable the module. - Seed the standard test environment via
scripts/seed-test-data.php(admin user, test category + product, TaxCloud + shipping-origin config, active shipping carrier + payment method, reindex, cache flush). - Run the PHPUnit integration suite via our custom bootstrap
(
Test/Integration/bootstrap.php).
Re-runs are idempotent — composer create-project is skipped if Magento is already installed at the target path; the schema and DB are always re-prepared so tests run against a known state.
Choosing a different edition / version / PHP¶
make integration-test MAGENTO_EDITION=community MAGENTO_VERSION=2.4.7-p3 PHP_VERSION=8.2
make integration-test MAGENTO_EDITION=enterprise MAGENTO_VERSION=2.4.8-p5
Precedence for the PHP version: make command line > PHP_VERSION in
.env > 8.3. Edition and version always come from the make command
line (or its community 2.4.8-p5 default) — the MAGENTO_EDITION /
MAGENTO_VERSION lines in .env only affect direct docker compose
invocations.
Other targets¶
make integration-shell # bash inside the app container
make integration-clean # tear down containers + volumes
integration-clean does not delete the Magento install directory itself
(that would force a full ~5-minute reinstall on the next run). Remove it
manually if you want a truly fresh start:
rm -rf ../magento-community-2.4.8-p5 # adjust to match your matrix row
How the test runtime is wired¶
Tests boot the actual installed Magento via a custom bootstrap at
Test/Integration/bootstrap.php. That
bootstrap calls Magento's Bootstrap::create(), grabs the ObjectManager,
and stashes it in
Taxcloud\Magento2\Test\Integration\TestEnvironment.
Tests pull it back via TestEnvironment::getObjectManager() (or the
shorthand TestEnvironment::get(SomeClass::class)).
We deliberately do not use Magento's dev/tests/integration/ framework.
That framework exists to test Magento core — it installs a second Magento
into a separate DB and wires up annotation-driven fixtures and isolation. For
exercising one module against an already-installed Magento, it adds nothing
and makes the install pipeline far more fragile.
How test data works¶
There is no committed DB dump. Instead, the install script applies a known
test state programmatically on top of a clean setup:install via
scripts/seed-test-data.php — a standalone
PHP script that bootstraps the installed Magento and uses standard Magento
APIs (repositories, config writer, indexers). Because the state is created
through the application rather than restored as SQL, the same script works
on any edition or version with no per-version artifacts to regenerate.
The seeded baseline every test can rely on:
| What | Value |
|---|---|
| Admin user | admin / 1234567a (admin@example.com, Administrators role) |
| Restricted admin user | tax-no-diagnostics / 1234567a (tax settings and order view, no TaxCloud Diagnostics Export grant — used by the E2E ACL checks) |
| Category | "Test Category" (test-category) |
| Product | test-product — simple, $10.00, in stock, in Test Category |
| Configurable | test-configurable on a test_variant_color attribute, with variants test-variant-red (TIC 20010) and test-variant-blue (TIC 00000); parent has no TIC |
| TaxCloud config | enabled, logging, verify_address = 1; default_tic = 20000; api_id/api_key from env |
| Shipping origin | 1401 Lavaca St, Austin TX 78701-1634 (region 57) |
| Checkout methods | carriers/flatrate + payment/checkmo active |
| Multi-store | Second website/group/store view (all code second), same root category, full test catalog assigned; tax/taxcloud_settings/enabled = 0 at stores/second scope (TaxCloud OFF there). web/url/use_store = 1, so storefronts are /default/... and /second/... on one base URL |
| Indexers / caches | All reindexed, all flushed |
The script is idempotent — re-running updates rather than duplicates. It can also be pointed at any other installed Magento (a local dev install, say) to reproduce the same baseline:
TAXCLOUD_API_ID=... TAXCLOUD_API_KEY=... TAXCLOUD_API_V3_KEY=... \
php scripts/seed-test-data.php /path/to/magento
If a future test needs more seed data, extend seed-test-data.php (keep it
idempotent) or INSERT from within the test via Magento's
ResourceConnection.
Magento's per-test annotations (
@magentoDataFixture,@magentoDbIsolation,@magentoConfigFixture) do not work here — they're framework features. If you need that style of isolation, you can wrap a test in a manual transaction:$conn = TestEnvironment::get(\Magento\Framework\App\ResourceConnection::class) ->getConnection(); $conn->beginTransaction(); try { // ... test work ... } finally { $conn->rollBack(); }
Mocking the TaxCloud SOAP client¶
Real TaxCloud SOAP calls cannot run in CI — they need live sandbox connectivity and would make tests slow, flaky, and dependent on an external service's state. Every behavioural test instead swaps the SOAP client for a controllable double while keeping the rest of Magento real (real DB, real order/invoice/credit-memo flow, real event dispatch, real observers). This is the same approach Magento core uses when stubbing external services.
How the swap works¶
Taxcloud\Magento2\Model\Api gets its client from Magento's
Magento\Framework\Webapi\Soap\ClientFactory (create() returns a
\SoapClient). The harness in
Test/Integration/IntegrationTestCase.php
does two things in installSoapMock():
- Rebinds the factory. It puts an anonymous
ClientFactorysubclass into the ObjectManager whosecreate()returns aRecordingSoapClientinstead of a real\SoapClient. - Evicts the cached singletons that would otherwise still hold the real
client —
Api, the TaxCloudTaxtotal model, and the four observers. On the next resolution Magento rebuilds that graph around the mock.
Why not
addSharedInstance()? That method only exists on Magento's integration-test-framework ObjectManager. This suite deliberately boots the real installed Magento (see "Why a custom bootstrap" below), whose production ObjectManager keeps its shared instances in a protected array. The harness reaches them with a closure bound to the ObjectManager's class scope — the minimal seam that lets us swap one binding without dragging in the whole framework.
RecordingSoapClient extends \SoapClient (so it satisfies the factory's
return contract) but never calls the parent constructor, so no WSDL is ever
fetched. Every TaxCloud operation is a magic method on \SoapClient, so the
double intercepts them all through __call(), where it both records the
call (method name + argument payload) and returns a canned response.
Canned responses¶
Reusable happy-path responses live as PHP fixtures under
Test/Integration/_files/soap_responses/,
one file per operation, each returning an array shaped like the real WSDL's
response element:
| Fixture | SOAP op (as the code calls it) | Result element |
|---|---|---|
lookup_ok_empty.php |
lookup |
LookupResult (OK, empty cart response = zero tax) |
verify_address_ok.php |
verifyAddress |
VerifyAddressResult (ErrNumber 0) |
get_exempt_certificates_empty.php |
GetExemptCertificates |
GetExemptCertificatesResult (OK, none) |
authorized_with_capture_ok.php |
authorizedWithCapture |
AuthorizedWithCaptureResult (OK) |
returned_ok.php |
Returned |
ReturnedResult (OK) |
order_details_captured.php |
OrderDetails |
OrderDetailsResult (non-empty CapturedDate) |
installSoapMock() loads all six by default. Note the keys match the casing
the code uses when calling the client (lookup, verifyAddress, …), not the
WSDL's PascalCase — __call() receives the name exactly as written in Api.
Override or add a response per test with
$soap->setResponse('OperationName', $arrayOrClosure); a \Closure(array $args)
lets you compute a per-call response.
Writing an observer-wiring test¶
Extend IntegrationTestCase, install the mock, drive a real sales-flow action,
then assert on what reached the SOAP layer:
final class CaptureOnOrderPlaceTest extends IntegrationTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->installSoapMock();
$this->setCaptureTrigger(CaptureTrigger::ORDER_CREATION);
}
public function testCaptureFiresOnPlacement(): void
{
$soap = $this->soapClient();
$order = $this->placeOrder(); // fires sales_order_place_after
$this->assertSame(1, $soap->callCount('authorizedWithCapture'));
$this->assertSame(
$order->getIncrementId(),
$soap->firstCallArgs('authorizedWithCapture')['orderID']
);
}
}
The base class provides the whole real lifecycle so the events actually fire:
placeOrder(), payInvoice(), createShipment(), cancelOrder(),
refundOrder(), plus setCaptureTrigger() / writeConfig() (which persist
config and reinit() the shared config). Current coverage lives in
Test/Integration/Observer/Sales/:
| Test | Proves |
|---|---|
CaptureOnOrderPlaceTest |
trigger=order_creation → capture on sales_order_place_after |
CaptureOnInvoicePayTest |
trigger=payment → capture on invoice pay, not on placement |
CaptureOnShipmentTest |
trigger=shipment → capture on shipment save only |
CancelOnRealOrderStateTransitionTest |
cancelling a captured, uninvoiced order calls Returned once across both cancel events |
RefundOnCreditmemoTest |
credit memo → Returned, with payload items matching the memo |
Multi-store scoping lives in
Test/Integration/MultiStore/, built on the
seeded second store view (TaxCloud disabled at stores/second) plus the
snapshot-restoring setScopedConfig() / setSecondStoreConfig() helpers and
the $storeCode parameter on the quote/order builders. These tests run from
the CLI, where the ambient store is NOT the second store — so passing proves
config resolves against the order's/quote's store, never the ambient one:
| Test | Proves |
|---|---|
StorefrontStoreScopeTest |
a second-store cart makes zero TaxCloud calls while the default store's does (TC-1); store-scoped default_tic/shipping_tic reach the Lookup payload (TC-6); store-scoped verify_address=0 suppresses VerifyAddress (TC-7) |
DisabledStoreViewAdminLifecycleTest |
admin cancel/refund of a disabled-store order produces no TaxCloud traffic — no OrderDetails probe, no Returned (TC-2, TC-3) |
EnabledStoreViewLifecycleTest |
with default scope disabled and the store view enabled under its own account, invoice capture and cancel reversal fire and carry the store view's credentials (TC-4, TC-5) |
Beyond observer wiring, Test/Integration/Model/
exercises tax collection, EAV, and config through the real stack:
| Test | Proves |
|---|---|
TaxCollectorWiringTest |
Magento's total-collector pipeline actually calls our Tax::collect(), and the mocked lookup tax flows into the item + grand total |
ModuleDisabledFallsBackToNativeTaxTest |
with enabled=0, Tax::collect() defers to native Magento tax (a real 8.25% rule) and makes no SOAP lookup |
ProductTicResolutionFromEavTest |
ProductTicService reads taxcloud_tic from the real installed EAV attribute |
ConfigurableProductVariantTicTest |
the chosen simple variant's TIC (not the parent's) reaches the lookup payload |
ConfigInjectionTest |
credentials in core_config_data are what Api::getApiId()/getApiKey() read back |
These tests place real orders / collect real quotes and therefore write to the test database — run them against the integration stack (
make integration-test), never the shared dev database. Each test creates and asserts on its own order, so they don't depend on each other's state.
Mocking the v3 REST transport¶
The v3 path has the same need and the same shape, with one difference: there is
no client factory to rebind, because RestClient is the transport seam. So
installRestMock() seeds a
RecordingRestClient
directly and evicts the REST-dependent singletons (the REST gateway and
certificate gateway, the router, the Tax collector, the observers, the
cancellation plugin, the Canada access checker, the diagnostics probe, and the
certificate repository, resolver and attachment writer).
The router is reached through two generated proxies — the gateway's and the certificate repository's — and each is a shared instance caching the subject it first built. Both are on the eviction list; a proxy left in place keeps serving the previous test's double (or the real transport) with nothing to show for it but assertions that see the wrong calls.
protected function setUp(): void
{
parent::setUp();
$this->installRestMock($this->restRespondersWith([
'POST /carts' => $this->flatRateCartResponder(0.13),
]));
// The seeded store runs on SOAP: a REST mock records nothing until the
// store is switched over.
$this->setScopedConfig('tax/taxcloud_settings/api_type', 'rest');
}
Responders are keyed "<METHOD> <path-prefix>" and matched longest prefix
first, so POST /orders/refunds is not served by the POST /orders entry.
defaultRestResponders() covers carts (zero tax), orders, refunds, order
details and verify-address; flatRateCartResponder() taxes every line it is
handed, the v3 counterpart of flatRateLookupResponder(). Auth is stubbed —
pingForScope() answers PingResult::OK unless a test calls
setPingResult() — so nothing needs credentials.
Assert on the recorder: callsTo('POST', '/carts'), callCount(),
firstBody(), or firstLookupCart() for the first cart of the first lookup.
resetCalls() clears the log between phases of one test.
Tests must not inherit each other's configuration¶
setCaptureTrigger() and setScopedConfig() snapshot what they change and
tearDown() puts it back, so a class cannot leave a setting behind for the
next one. State what your test needs in setUp() — the seeded store captures
on payment, so a test asserting a capture at order placement has to set
CaptureTrigger::ORDER_CREATION itself.
This is not hypothetical: four classes silently depended on another class having left the trigger at order-creation, and adding one new test class — which changed the order — turned that into ten failures whose own code looked correct. PHPUnit's result cache reorders defect-first locally, so the same suite can fail on one machine and pass in CI purely on ordering.
The CI matrix¶
The workflow currently runs four rows:
| Edition | Magento | PHP | MariaDB | OpenSearch |
|---|---|---|---|---|
| community | 2.4.8-p5 | 8.3 | 10.6 | 2.12 |
| community | 2.4.9 | 8.5 | 11.4 | 3.0 |
| enterprise | 2.4.8-p5 | 8.3 | 10.6 | 2.12 |
| enterprise | 2.4.9 | 8.5 | 11.4 | 3.0 |
Enterprise rows skip cleanly (not fail) when the MAGENTO_PUBLIC_KEY
secret is unset, so forks without a Commerce contract still get green
community rows. Note that enterprise rows need Marketplace keys from an
account entitled to Adobe Commerce — ordinary free Marketplace keys can
download Open Source but get a 403 from repo.magento.com for
project-enterprise-edition.
Adding a new Magento version¶
There are no per-version artifacts to generate — the seed script works against any installed Magento. Adding a version is two steps:
- Add a row to the
integrationjob's matrix in.github/workflows/test.yml(add both editions, and a matching row to theunit-testsmatrix if it's a new version line):- magento-edition: community magento-version: '2.4.10' php-version: '8.5' - magento-edition: enterprise magento-version: '2.4.10' php-version: '8.5' - Test the row before merging: Actions tab → CI → Run workflow → set
magento_versions=2.4.10(dispatch runs the integration job too).
If a row needs a different PHP version than the others, the compose stack
already supports it — the PHP_VERSION env var is honored both locally and
in CI. The image is markoshust/magento-php:<PHP_VERSION>-fpm.
MariaDB and OpenSearch versions are derived from the Magento version by
scripts/install-magento.sh per Adobe's system requirements (2.4.9 dropped
MariaDB 10.x and OpenSearch 2.x). If a future Magento release changes the
requirements again, extend the case block in the install script — or
override per-run with the MARIADB_VERSION / OPENSEARCH_VERSION env
vars.
Triggering the integration matrix manually¶
Integration runs as a job within the unified CI workflow
(.github/workflows/test.yml). It is gated to release tags (v*) and manual
dispatch — it does not run on ordinary PRs or branch pushes.
From the GitHub UI:
- Actions tab → CI workflow.
- Run workflow → choose the branch.
- Optional: set Integration: comma-separated versions (e.g.
2.4.8-p5to run only that version's matrix rows). Empty = full matrix. - Watch the per-row jobs. On failure, the debug-\<edition>-\<version>
artifact contains
docker compose logsand Magento'svar/log/*.logtail — start there.
From the CLI:
gh workflow run test.yml --ref main
gh workflow run test.yml --ref main -f magento_versions=2.4.8-p5
A pushed release tag (v*) runs the whole thing — unit + the full integration
matrix — in a single CI run.
Required GitHub Actions secrets¶
| Secret | Purpose | Required for |
|---|---|---|
TAXCLOUD_API_ID |
Sandbox API ID | All rows |
TAXCLOUD_API_KEY |
Sandbox API key | All rows |
TAXCLOUD_API_V3_KEY |
v3 REST key (Developer → API) | All rows |
MAGENTO_PUBLIC_KEY |
Marketplace public key (auth.json user) | All rows |
MAGENTO_PRIVATE_KEY |
Marketplace private key (auth.json pass) | All rows |
Enterprise rows skip cleanly (not fail) when MAGENTO_PUBLIC_KEY is unset,
so forks without Commerce contracts still get green community rows.
Architecture¶
Why markoshust/docker-magento?¶
We chose markoshust/docker-magento as the container base over rolling our own minimal Compose. Reasons:
- The PHP image bundles every extension Magento needs (
gd,intl,soap,xsl,zip,bcmath,pcntl,sockets,redis,opcache, …). Replicating that in a hand-rolled Dockerfile is ~50 lines ofapt-get installandpecl installwe'd have to keep current. - It's used widely in the Magento community, so when something breaks there's a body of public knowledge to draw from.
- Versions track Magento's PHP support matrix closely. Bumping PHP for a new Magento release is a tag bump.
We deliberately use only their PHP image, not their full stack — nginx/varnish/etc. add nothing for integration tests, which don't go through HTTP.
Local vs CI layout¶
| Aspect | Local | CI |
|---|---|---|
| Magento install | Sibling dir (../magento-<edition>-<v>) |
Workspace-adjacent ($GITHUB_WORKSPACE/../…) |
| Module mount | Bind from this repo into /srv/module |
Bind from $GITHUB_WORKSPACE into /srv/module |
| Composer cache | ./.composer-cache (gitignored) |
$GITHUB_WORKSPACE/.composer-cache + actions/cache |
| Credentials | .env (gitignored) |
Repo secrets injected as job env |
| Lifetime | Persistent across make runs |
Wiped between matrix rows |
Both paths drive the same scripts/install-magento.sh and docker-compose.yml.
Why a custom bootstrap instead of Magento's framework?¶
Magento's dev/tests/integration/ framework installs a second Magento into
a separate DB (magento_integration_test), wires up
@magentoDataFixture / @magentoDbIsolation / @magentoConfigFixture
annotations, and otherwise reproduces the full test-isolation model used by
Magento core's own test suite.
For one third-party module, that machinery is pure cost:
- A second Magento install means a second setup:install to maintain.
- A second DB means a second source of truth for fixture data.
- Every PHPUnit constant (TESTS_INSTALL_CONFIG_FILE etc.) is another
file you have to write into Magento's tree at install time.
Our custom bootstrap is ~20 lines, boots the installed Magento, and lets
tests share state. If you ever need per-test isolation, you can wrap a test
in a manual transaction (see "How test data works" earlier). If you need
seed data, add it to scripts/seed-test-data.php or INSERT it from the
test.
Troubleshooting¶
ERROR: TAXCLOUD_API_ID, TAXCLOUD_API_KEY and TAXCLOUD_API_V3_KEY must be set¶
You haven't filled in .env (locally) or set repo secrets (CI). See the
"Prerequisites" section.
Composer could not authenticate against repo.magento.com¶
Your Marketplace keys are wrong, expired, or revoked. Regenerate at
https://commercemarketplace.adobe.com/customer/accessKeys/ and update
.env / secrets.
OpenSearch OOM-killed on first boot¶
The compose file sets -Xms512m -Xmx512m. If your Docker host is tight on
memory (less than 6 GB allocated to Docker), bump those down to 256m in
docker-compose.yml or give Docker more RAM.
make integration-test fails inside setup:di:compile¶
Almost always a stale generated/ directory in the Magento install. From
the host:
docker compose exec -w /var/www/html app rm -rf generated/code generated/metadata
make integration-test
Connection refused / "Unknown database 'magento'" after make integration-clean¶
make integration-clean wipes the DB volume, but the Magento install on
disk still has a stale app/etc/env.php pointing at the old DB. The
install script's defensive reset re-creates the DB but env.php gets
written by setup:install, so you just need to let the script run. If
something gets really wedged:
rm -rf ../magento-community-2.4.8-p5
make integration-test