Asserting the output of Symfony console commands

If you write console commands using symfony/console and have not used SymfonyStyleyet, you should give it a try.

SymfonyStyle provides additional methods for rendering output in the terminal. These methods allow rendering titles, section headings, success and error messages, tables, bullet lists, and more - all of them nicely formatted and indented.

Here is an example from ergebnis/day-one-to-obsidian-converter:

<?php

declare(strict_types=1);

namespace Ergebnis\DayOneToObsidianConverter\Outside\Adapter\Primary\Console;

use Ergebnis\DayOneToObsidianConverter\Inside;
use Symfony\Component\Console;

final class ConvertCommand extends Console\Command\Command
{
    protected function execute(
        Console\Input\InputInterface $input,
        Console\Output\OutputInterface $output,
    ): int {
        $io = new Console\Style\SymfonyStyle(
            $input,
            $output,
        );

        // ...

        $dayOneDirectory = $input->getArgument('day-one-directory');

        if (!\is_dir($dayOneDirectory)) {
            $io->error(\sprintf(
                'DayOne directory %s does not exist',
                $dayOneDirectory,
            ));

            return self::FAILURE;
        }

        // ...

        return self::SUCCESS;
    }
}

While the error message looks great, when you write tests and want to make assertions about the output generated by the command, you quickly run into issues.

If the length of an error or success message depends on variable input, you can never be sure whether a message can fit into a single or will spread across multiple lines.

<?php

namespace Ergebnis\DayOneToObsidianConverter\Test\Integration\Outside\Adapter\Primary\Console;

use Ergebnis\DayOneToObsidianConverter\Outside;
use Ergebnis\DayOneToObsidianConverter\Test;
use PHPUnit\Framework;
use Symfony\Component\Console;

final class ConvertCommandTest extends Framework\TestCase
{
    use Test\Util\Helper;

    public function testExecuteFailsWhenDayOneDirectoryDoesNotExist(): void
    {
        // ...

        $application = self::createApplication();

        $input = new Console\Input\ArrayInput([
            'day-one-directory' => $dayOneDirectory,
        ]);

        $output = new Console\Output\BufferedOutput();

        $exitCode = $application->run(
            $input,
            $output,
        );

        self::assertSame(Console\Command\Command::FAILURE, $exitCode);

        $expected = \sprintf(
            'DayOne directory %s does not exist',
            $dayOneDirectory,
        );

        self::assertStringContainsString($expected, $output->fetch());
    }

    // ...
}

Unfortunately, the test above fails because the actual error message is wrapped across multiple lines.

While Symfony allows specifying the terminal width via a COLUMNS environment variable, SymfonyStyle does not accept values greater than 120 characters. Setting the COLUMNS environment variable to a value larger than 120 in the context of tests will not work.

You could try working around the formatting and indenting by fiddling with regular expressions, but why bother?

Instead, let SymfonyStyle generate the expected output!

<?php

declare(strict_types=1);

namespace Ergebnis\DayOneToObsidianConverter\Test\Util;

use Faker\Factory;
use Faker\Generator;
use Symfony\Component\Console;

trait Helper
{
    // ...

    /**
     * @param \Closure(OutputStyle):void $closure
     */
    final protected static function captureConsoleOutput(\Closure $closure): string
    {
        $output = new Console\Output\BufferedOutput();

        $io = new Console\Style\SymfonyStyle(
            new Console\Input\ArrayInput([]),
            $output,
        );

        $closure($io);

        return $output->fetch();
    }

    // ...
}

Now you can simplify your tests by asserting that the actual output contains the expected output.


         self::assertSame(Console\Command\Command::FAILURE, $exitCode);

-        $expected = \sprintf(
-            'DayOne directory %s does not exist',
-            $dayOneDirectory,
-        );
+        $expected = self::captureConsoleOutput(static function (Console\Style\OutputStyle $io) use ($dayOneDirectory): void {
+            $io->error(\sprintf(
+                'DayOne directory %s does not exist',
+                $dayOneDirectory,
+            ));
+        });

         self::assertStringContainsString($expected, $output->fetch());
     }

     // ...
 }

Do you have a better idea?

Do you find this article helpful?

Do you have feedback?

Do you need help with your PHP project?