Testing Strategy
merql uses an oracle-driven testing model. Each test scenario defines a known set of inputs (three database states), a deterministic expected result (the oracle), and compares the actual merge output against that expectation. 32 scenarios cover clean merges, conflicts, identity resolution, data types, scale, and edge cases.
# Run the full verification suite.
./bin/verify-all
The commands on this page run from a checkout of the merql repository after
composer install. The development scripts in bin/, the scenarios/
corpus, and the tests are not part of the Composer package. The development
dependencies include PHPUnit 13, which needs PHP 8.4 or later, even though the
library itself runs on PHP 8.2.
Oracle-driven testing
There is no existing reference tool that performs three-way database merge, so the oracle is computed from known inputs rather than generated by an external tool. Each scenario has a deterministic expected result that can be verified by logic:
- Setup -- create three database states (base, ours, theirs) with known content.
- Oracle -- compute the expected merge result from the known inputs.
- Actual -- run merql's merge on the three states.
- Compare -- the actual result must match the expected result exactly.
Known inputs (base, ours, theirs)
| |
Deterministic merql merge
computation
| |
Expected result Actual result
| |
-----> Compare <-----
|
Pass or Fail
Running tests
Full verification
The verify-all script is the final gate. It runs static analysis, coding standards, PHPUnit tests, and the oracle regression suite:
./bin/verify-all
This must pass before any work is considered complete.
Oracle regression suite
Run all 32 scenarios:
./bin/test-regression
Run a single category:
./bin/test-regression --category clean
./bin/test-regression --category conflict
./bin/test-regression --category identity
Categories are the directory names under scenarios/: clean, conflict,
identity, types, scale, and edge. An unknown category prints
No scenarios found and exits successfully. --category can be repeated to run
several categories.
The script also accepts --jobs, but it ignores the value and runs scenarios
one after another (#5).
Run with minimal output (pass/fail only):
./bin/test-regression --fast
Single scenario
Test one scenario through the full pipeline:
./bin/test-scenario column-level-clean
Individual oracle steps
./bin/oracle column-level-clean # Compute expected result
./bin/actual column-level-clean # Run merql, capture output
./bin/compare column-level-clean # Diff oracle vs actual
Unit tests
composer test:unit # Isolated component tests
composer test:integration # Integration tests (SQLite, plus MySQL when configured)
composer test # All PHPUnit suites
composer test:oracle # Oracle regression corpus (bin/test-regression)
composer verify # analyse + cs + test + test:oracle
composer test runs PHPUnit only. The oracle PHPUnit suite points at
tests/Oracle, which holds the scenario runner classes but no test cases, so
the 32 scenarios run only through composer test:oracle, composer verify, or
./bin/verify-all.
The MySQL integration tests are skipped unless MERQL_MYSQL_HOST,
MERQL_MYSQL_DATABASE, and MERQL_MYSQL_USER (plus optionally
MERQL_MYSQL_PASSWORD and MERQL_MYSQL_PORT) point at a test database.
Code quality
composer cs # PSR-12 coding standards check
composer cs:fix # Auto-fix coding standards
composer analyse # PHPStan level 10 static analysis
Scenario categories
clean (8 scenarios)
Clean merges with no conflicts. Both sides may change data, but never the same column of the same row.
| Scenario | Tests |
|---|---|
insert-only-theirs |
Theirs inserted rows, ours unchanged. |
update-only-theirs |
Theirs updated rows, ours unchanged. |
delete-only-theirs |
Theirs deleted rows, ours unchanged. |
insert-only-ours |
Ours inserted rows, theirs unchanged. |
mixed-no-overlap |
Both changed different tables or rows. |
both-same-change |
Both made identical changes (no conflict). |
column-level-clean |
Both changed same row, different columns. |
multi-table |
Changes across multiple tables. |
conflict (6 scenarios)
Scenarios that must produce conflicts.
| Scenario | Tests |
|---|---|
both-update-same-column |
Both changed same column to different values. |
update-vs-delete |
One updated, other deleted same row. |
delete-vs-update |
Reverse direction of update-vs-delete. |
both-insert-same-pk |
Both inserted row with same primary key. |
multiple-conflicts |
Several conflicts in one merge. |
partial-conflict |
Some columns conflict, others merge clean. |
identity (4 scenarios)
Row identity edge cases.
| Scenario | Tests |
|---|---|
auto-increment |
New rows have different IDs across branches. |
natural-key |
Match by unique columns instead of PK. |
composite-key |
Multi-column primary key. |
no-key |
Table without primary key (content hash fallback). |
types (6 scenarios)
Data type handling across all column types.
| Scenario | Tests |
|---|---|
text-columns |
VARCHAR, TEXT, LONGTEXT values. |
numeric-columns |
INT, DECIMAL, FLOAT values. |
date-columns |
DATE, DATETIME, TIMESTAMP values. |
json-columns |
JSON column merge. |
blob-columns |
Binary data. |
null-handling |
NULL to value, value to NULL transitions. |
scale (4 scenarios)
Performance under load.
| Scenario | Tests |
|---|---|
1k-rows |
1,000 rows. |
10k-rows |
10,000 rows. |
100k-rows |
100,000 rows. |
wide-table |
Table with 50+ columns. |
edge (4 scenarios)
Edge cases and boundary conditions.
| Scenario | Tests |
|---|---|
empty-changeset |
No changes on one or both sides. |
schema-mismatch |
Column added or removed between snapshots. |
encoding |
UTF-8, emoji, special characters. |
large-text |
Very large TEXT/LONGTEXT values. |
Scenario structure
Each scenario is a directory under scenarios/<category>/<name>/ with a scenario.json configuration:
{
"name": "column-level-clean",
"category": "clean",
"description": "Both sides modify same row but different columns",
"tables": ["test_posts"],
"expectations": {
"conflicts": 0,
"operations": 3,
"changeset_match": "exact",
"merge_match": "exact",
"sql_match": "semantic"
}
}
For conflict scenarios, the expected conflicts are specified in detail:
{
"expectations": {
"conflicts": 1,
"conflict_details": [
{
"table": "test_posts",
"primary_key": {"id": 42},
"column": "title",
"ours_value": "Welcome",
"theirs_value": "Greetings"
}
]
}
}
The ScenarioRunner pipeline
ScenarioRunner::run() orchestrates a single scenario:
OracleCapture::compute()builds three snapshots from the scenario's JSON data and runs the merge.ScenarioComparator::compare()checks the merge result against the expected values inscenario.json.- Returns pass/fail with a list of failure messages.
use Merql\Tests\Oracle\ScenarioRunner;
$result = ScenarioRunner::run($scenario);
// ['name' => 'column-level-clean', 'pass' => true, 'failures' => []]
ScenarioRunner::runAll() runs every scenario in sequence:
$results = ScenarioRunner::runAll($scenarios);
Adding a new scenario
- Create a directory:
scenarios/<category>/<name>/. - Create
scenario.jsonwith the scenario metadata and expectations. - Define the three database states in the JSON configuration: base data, ours mutations, theirs mutations.
- Run the scenario:
./bin/test-scenario <name>. - Verify the full suite:
./bin/verify-all.
Code quality standards
merql enforces:
- PHPStan level 10 -- strict static analysis with no baseline exceptions.
- PSR-12 -- coding standards checked by
phpcs. - Oracle regression -- all 32 scenarios must pass.
- Unit tests -- isolated tests for each component.
The verify-all script runs all four checks in sequence. No partial sign-off is accepted.
Continuous integration
CI runs coding standards, PHPStan, composer test, the integration suite
against MySQL 8.4 through mysqli, merge primitive coverage, and
composer audit on every push and pull request to main. It does not run the
oracle regression corpus (#6), so
run composer test:oracle or ./bin/verify-all locally before relying on a
change.