PHPUnit test reporting
--log-junit gives you an XML file that is JUnit-shaped
but carries more than JUnit can express — failures against errors, risky tests, assertion counts.
Qualflare reads those distinctions rather than flattening them,
and turns every run into hosted, historical reporting:
AI clustering of failures by root cause and flaky scores from run history.
Send PHPUnit results to Qualflare
One flag on the command you already run, or one block in
phpunit.xml so CI needs no flag at all:
# PHPUnit writes JUnit-style XML with its own extensions
vendor/bin/phpunit --log-junit phpunit-results.xml
# Or configure it once in phpunit.xml
# <logging><junit outputFile="phpunit-results.xml"/></logging> Then upload it:
qf my-project collect phpunit-results.xml --format phpunit In CI that's one added step. Authenticate the CLI once with your Qualflare access token, stored as a CI secret — see the CLI docs.
# .github/workflows/tests.yml
- name: Run PHPUnit
run: vendor/bin/phpunit --log-junit phpunit-results.xml
- name: Upload results to Qualflare
if: always() # upload even when tests fail — that's the point
run: qf my-project collect phpunit-results.xml --format phpunit
The if: always() line is the one people leave out.
PHPUnit exits non-zero on failures, so without it the upload runs only on green builds and your failure
history — the entire point — stays empty.
Use --format phpunit, not --format junit
PHPUnit's log is JUnit-shaped, so reading it as generic JUnit XML appears to work. It quietly throws away the two things PHPUnit adds. The first is the distinction between a failure and an error:
<testcase name="testExpiredCard" class="App\Tests\CheckoutTest" assertions="3" time="0.041">
<failure type="PHPUnit\Framework\ExpectationFailedException">
Failed asserting that false is true. <!-- an assertion did not hold -->
</failure>
</testcase>
<testcase name="testCheckoutTotal" class="App\Tests\CheckoutTest" assertions="0" time="0.002">
<error type="TypeError">
Unsupported operand types: string + int <!-- the code blew up -->
</error>
</testcase> <failure>— your assertion ran and did not hold. The code executed; the expectation was wrong, or the behaviour changed. This is a test telling you something about the system.<error>— the test never reached its assertion. ATypeError, an uncaught exception, a missing fixture. Often a different person's problem and always a different fix.assertions="3"— how many assertions actually ran. Generic JUnit XML has no field for this at all, so the number is simply lost in translation.
Collapsing errors into failures is the easy mistake, and it has a visible symptom worth checking for in any tool you use: an error count that is permanently zero while errors clearly occur. If every exception in your suite is being filed as an assertion failure, your triage is starting from the wrong question.
Risky tests, and the suite that verifies nothing
PHPUnit is unusually principled here. A test that asserts nothing is not a passing test — it is a risky one:
public function testSomethingUseful(): void
{
$this->service->process($order);
// No assertion. PHPUnit reports this as RISKY, not as a pass:
// beStrictAboutTestsThatDoNotTestAnything defaults to true.
} beStrictAboutTestsThatDoNotTestAnything defaults to
true, so PHPUnit flags it instead of counting it green
unless you have explicitly turned that off. Qualflare records risky tests as errors rather than passes, preserving
that judgement, because a suite quietly accumulating assertion-free tests is accumulating
test debt that looks like coverage.
It is a rare and good default. Most frameworks will happily let a test with no assertion pass forever —
see the same problem from the API side in Newman
reporting, where a request with no pm.test is
simply green.
What you get on top of the XML
- Failures, errors and risky tests kept apart. Three different problems, three different counts, instead of one red number.
- Data-provider rows as first-class cases. Each data set keeps its own status, duration and history.
- AI failure clustering. When one broken fixture or a downed dependency takes out 40 tests, they group into one root cause instead of 40 stack traces.
- Flaky scoring from history. PHPUnit has no built-in retry, so there is no in-run attempt data to read — history is not just the better method here, it is the only one.
- Paratest and sharded jobs merged. One launch per run, not one per worker.
- History, trends & defects. Pass rate, slowest tests, and flakiness over time across branches.
PHPUnit's log vs Qualflare
| --log-junit XML | Qualflare | |
|---|---|---|
| History across CI runs | — | Yes |
| Flaky scoring over time | — | Yes |
| AI failure clustering (root cause) | — | Yes |
| Merges Paratest / sharded jobs | — | Yes |
| Failure / error / risky distinction | Yes | Yes |
| Local, zero-setup, offline | Yes | — |
Complementary: keep --log-junit and your CI's own test
tab for the per-run view, add Qualflare for hosted, historical observability. Qualflare does not run your
tests.
Get AI analysis on your PHPUnit runs
Start free — add --log-junit, run qf collect, and get your first AI analysis in minutes.
Qualflare works the same with pytest, JUnit, RSpec, Jest, Go and 20+ more frameworks. Curious how standard the format really is? See the JUnit XML format guide — PHPUnit is one of the dialects it documents. Weighing tools? See how it compares to other test management platforms, or browse all framework reporting guides. Every reporter is open source.
Frequently asked questions
How do I send PHPUnit results to Qualflare?
Run vendor/bin/phpunit --log-junit phpunit-results.xml, then upload it with qf my-project collect phpunit-results.xml --format phpunit. You can also set the output file once in phpunit.xml under <logging><junit outputFile="..."/></logging> so CI needs no extra flag. No change to your tests is required.
Why use --format phpunit instead of --format junit?
Because PHPUnit’s XML is JUnit-shaped but not plain JUnit, and the differences carry information. It distinguishes <failure> (an assertion did not hold) from <error> (an exception, or a risky test), and it records an assertions count per test that generic JUnit XML has no field for. Reading it as plain JUnit folds errors into failures and drops the assertion counts.
What is the difference between a PHPUnit failure and an error?
A failure means your assertion ran and did not hold — the code worked, the expectation was wrong, or the behaviour changed. An error means the test never got far enough to assert: a TypeError, an uncaught exception, a missing fixture. They usually call for different people and different fixes, which is why they are kept apart rather than merged into one red count. A parser that conflates them reports an error count that is permanently zero.
How are risky tests reported?
As errors rather than passes. PHPUnit marks a test risky when it asserts nothing, leaves output behind, or performs unexpected I/O, and beStrictAboutTestsThatDoNotTestAnything defaults to true, so an assertion-free test is flagged rather than quietly counted as green unless you turn that off. That is a genuinely useful signal: a suite accumulating risky tests is accumulating tests that verify nothing.
Do data providers appear as separate tests?
Yes. Each data set becomes its own <testcase> in the XML and therefore its own case with its own status, duration and history. That matters for flaky detection: if one parameter combination is intermittent, per-row history points straight at it, whereas collapsing the rows into their parent method averages the signal away.
Does it work with Paratest and parallel runs?
Yes. Paratest writes the same JUnit-style log, so the upload is unchanged. For a suite split across several CI jobs, run collect once per job against the same run id — jobs derive it from the CI build automatically, or you can pass --run-id — and Qualflare merges them into one launch rather than one launch per worker.
Setup reflects the Qualflare CLI (docs.qualflare.com) as of September 2026. Flags and the risky-test behaviour follow PHPUnit's own documentation; the failure/error/risky handling and the assertions total are taken from the CLI's PHPUnit parser, which reads PHPUnit's XML rather than treating it as generic JUnit. Written by İbrahim Süren, Qualflare.