{"record":{"id":"b048eaed6adae2bb","repo":"sebastianbergmann/phpunit","slug":"iteratoraggregate-getiterator-returned-an-objec","errorCode":null,"errorMessage":"IteratorAggregate::getIterator() returned an object that was already seen","messagePattern":"IteratorAggregate::getIterator\\(\\) returned an object that was already seen","errorType":"exception","errorClass":"PHPUnit\\Framework\\Exception","httpStatus":null,"severity":"error","filePath":"src/Framework/Constraint/Cardinality/Count.php","lineNumber":91,"sourceCode":"    /**\n     * @throws Exception\n     */\n    protected function getCountOf(mixed $other): ?int\n    {\n        if (is_countable($other)) {\n            return count($other);\n        }\n\n        if ($other instanceof EmptyIterator) {\n            return 0;\n        }\n\n        if ($other instanceof Traversable) {\n            $context = new Context;\n\n            while ($other instanceof IteratorAggregate) {\n                if ($context->contains($other) !== false) {\n                    throw new Exception('IteratorAggregate::getIterator() returned an object that was already seen');\n                }\n\n                $context->add($other);\n\n                try {\n                    $other = $other->getIterator();\n                } catch (\\Exception $e) {\n                    throw new Exception(\n                        $e->getMessage(),\n                        $e->getCode(),\n                        $e,\n                    );\n                }\n            }\n\n            $iterator = $other;\n\n            if ($iterator instanceof Generator) {","sourceCodeStart":73,"sourceCodeEnd":109,"githubUrl":"https://github.com/sebastianbergmann/phpunit/blob/f123cdb2a2d49f15025794166cfed8bda8627dd2/src/Framework/Constraint/Cardinality/Count.php#L73-L109","documentation":"PHPUnit's Count constraint (used by assertCount()) counts Traversables by unwrapping IteratorAggregate::getIterator() chains until it reaches a real Iterator. Every aggregate visited is recorded in a RecursionContext; if a getIterator() call returns an aggregate that was already seen, the chain is cyclic and would loop forever, so PHPUnit aborts with this exception. It is a defect in the object under test's iterator wiring, not in the assertion itself.","triggerScenarios":"Calling assertCount($n, $traversable) (or any assertion that evaluates a Count constraint, including assertThat with Count) on an object whose getIterator() returns $this or returns another IteratorAggregate that eventually leads back to an already-visited aggregate. Typical: a class implements both IteratorAggregate and Iterator and does `return $this;`, or a decorator's getIterator() returns the decorated aggregate instead of its iterator.","commonSituations":"Lazy-collection or repository classes that implement both Iterator and IteratorAggregate; wrappers/decorators added around a collection during refactoring; entities converted to iterables where getIterator() was stubbed with `return $this;` to satisfy an interface; rarely, ORM/Collection libraries (Doctrine Collections, Laravel collections) wrapped in custom adapters.","solutions":["Make getIterator() return a fresh Iterator instance (e.g. new ArrayIterator($this->items)) or the inner iterator itself, never $this or an ancestor aggregate in the chain","If the class is itself an Iterator, remove the IteratorAggregate implementation so PHPUnit counts it directly without unwrapping","For a decorator, return the decorated object's iterator: `return $this->inner->getIterator();` or `return $this->inner instanceof Iterator ? $this->inner : $this->inner->getIterator();`","As a workaround in the test, count a materialized copy: `assertCount($n, iterator_to_array($object));` (only safe when the chain terminates for iterator_to_array)"],"exampleFix":"// before\nclass Playlist implements IteratorAggregate, Iterator\n{\n    public function getIterator(): Iterator { return $this; } // cycle: itself\n    // ...\n}\nassertCount(3, new Playlist);\n\n// after\nclass Playlist implements IteratorAggregate\n{\n    public function getIterator(): ArrayIterator { return new ArrayIterator($this->tracks); }\n}\nassertCount(3, new Playlist);","handlingStrategy":"validation","validationCode":"// Before assertCount() on a suspect aggregate, verify the chain terminates:\nfunction terminates(IteratorAggregate $root): bool\n{\n    $seen = [];\n    $node = $root;\n    while ($node instanceof IteratorAggregate) {\n        if (in_array($node, $seen, true)) {\n            return false; // cycle: PHPUnit would abort\n        }\n        $seen[] = $node;\n        $node = $node->getIterator();\n    }\n    return true;\n}\n\nif (!terminates($playlist)) {\n    $this->addWarning('Playlist::getIterator() is cyclic; fix the production code.');\n}\nassertCount(3, $playlist);","typeGuard":"function isNonCyclicIteratorAggregate(IteratorAggregate $root): bool\n{\n    $seen = [];\n    $node = $root;\n    while ($node instanceof IteratorAggregate) {\n        if (in_array($node, $seen, true)) {\n            return false;\n        }\n        $seen[] = $node;\n        $node = $node->getIterator();\n    }\n    return true;\n}","tryCatchPattern":"try {\n    assertCount(3, $traversable);\n} catch (\\PHPUnit\\Framework\\Exception $e) {\n    if (str_contains($e->getMessage(), 'already seen')) {\n        $this->markTestIncomplete('Cyclic IteratorAggregate in SUT: ' . $e->getMessage());\n    }\n    throw $e;\n}","preventionTips":["Never implement getIterator() as `return $this;` — if the class is already an Iterator, drop IteratorAggregate","In decorators, return $this->inner->getIterator(), not $this->inner","Static analysis rule: flag methods returning $this from getIterator()"],"tags":["php","phpunit","assertcount","iteratoraggregate","infinite-loop","traversable"],"backgroundTag":"cyclic-iterator-detected","analyzedSha":"f123cdb2a2d49f15025794166cfed8bda8627dd2","analyzedAt":"2026-08-23T01:20:58.058Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}