sebastianbergmann/phpunit · error · PHPUnit\Framework\Exception

IteratorAggregate::getIterator() returned an object that was

Error message

IteratorAggregate::getIterator() returned an object that was already seen

What it means

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.

Source

Thrown at src/Framework/Constraint/Cardinality/Count.php:91

    /**
     * @throws Exception
     */
    protected function getCountOf(mixed $other): ?int
    {
        if (is_countable($other)) {
            return count($other);
        }

        if ($other instanceof EmptyIterator) {
            return 0;
        }

        if ($other instanceof Traversable) {
            $context = new Context;

            while ($other instanceof IteratorAggregate) {
                if ($context->contains($other) !== false) {
                    throw new Exception('IteratorAggregate::getIterator() returned an object that was already seen');
                }

                $context->add($other);

                try {
                    $other = $other->getIterator();
                } catch (\Exception $e) {
                    throw new Exception(
                        $e->getMessage(),
                        $e->getCode(),
                        $e,
                    );
                }
            }

            $iterator = $other;

            if ($iterator instanceof Generator) {

View on GitHub (pinned to f123cdb2a2)

Solutions

  1. 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
  2. If the class is itself an Iterator, remove the IteratorAggregate implementation so PHPUnit counts it directly without unwrapping
  3. For a decorator, return the decorated object's iterator: `return $this->inner->getIterator();` or `return $this->inner instanceof Iterator ? $this->inner : $this->inner->getIterator();`
  4. 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)

Example fix

// before
class Playlist implements IteratorAggregate, Iterator
{
    public function getIterator(): Iterator { return $this; } // cycle: itself
    // ...
}
assertCount(3, new Playlist);

// after
class Playlist implements IteratorAggregate
{
    public function getIterator(): ArrayIterator { return new ArrayIterator($this->tracks); }
}
assertCount(3, new Playlist);
Defensive patterns

Strategy: validation

Validate before calling

// Before assertCount() on a suspect aggregate, verify the chain terminates:
function terminates(IteratorAggregate $root): bool
{
    $seen = [];
    $node = $root;
    while ($node instanceof IteratorAggregate) {
        if (in_array($node, $seen, true)) {
            return false; // cycle: PHPUnit would abort
        }
        $seen[] = $node;
        $node = $node->getIterator();
    }
    return true;
}

if (!terminates($playlist)) {
    $this->addWarning('Playlist::getIterator() is cyclic; fix the production code.');
}
assertCount(3, $playlist);

Type guard

function isNonCyclicIteratorAggregate(IteratorAggregate $root): bool
{
    $seen = [];
    $node = $root;
    while ($node instanceof IteratorAggregate) {
        if (in_array($node, $seen, true)) {
            return false;
        }
        $seen[] = $node;
        $node = $node->getIterator();
    }
    return true;
}

Try / catch

try {
    assertCount(3, $traversable);
} catch (\PHPUnit\Framework\Exception $e) {
    if (str_contains($e->getMessage(), 'already seen')) {
        $this->markTestIncomplete('Cyclic IteratorAggregate in SUT: ' . $e->getMessage());
    }
    throw $e;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of sebastianbergmann/phpunit@f123cdb2a2 (2026-08-23). Data as JSON: /api/errors/b048eaed6adae2bb. Report an issue: GitHub.