Quick and easy PHP code generation testing

in #php7 years ago (edited)

Recently I was working on some PHP code (for the PHP-FIG) that involved code generation. Lots of systems these days are doing code generation (compiled dependency injection containers, ORM classes, etc.), but surprisingly I've avoided having to touch that code myself until now.

Of course, like any good developer I was writing tests for it as I went. That meant needing to test that the generated code was valid PHP syntax and did what I wanted it to do. I asked a few fellow devs for suggestions and they offered two:

  • Generate the code to a string and then compare it against a known string. This fails because it's super brittle and doesn't test that the code runs the way I want, just that it generates the precise string I thought it would.
  • Use a library like PHP-VFS to write to a virtual file system. This is nice and clean, but I couldn't find any way to then execute the code; it wasn't much better than the string approach.

What I really wanted was to write the compiled file out to somewhere I could then have PHP include it. PHP has a very powerful streams library that among other things lets you write to a temp file in memory, but as far as I can tell there's no way to then include that temp file to parse it.

However! PHP also lets you open temp files through the operating system. And here we have our deceptively simple answer.

The following simplified PHPUnit test does exactly what we need:

class CodeCompilerTest extends TestCase
{
    function testCompile()
    {
        $class = 'CompiledTest';

        $compiler = new Compiler();

        try {
            // This is the fun part.  We create a randomly named 
            // file in the system temp directory, with a "compiled" 
            //prefix.  Since the name is generated fresh each
            // time we won't get collisions (and if we do the
            //  file will get overwritten anyway).
            $filename = tempnam(sys_get_temp_dir(), 'compiled');
            $out = fopen($filename, 'w');

            // The actual code we're testing.  Pass it the open 
            // stream and have it write the compiled code
            //  straight to the stream. No intermediary string.
            // We also tell it the class name to use rather 
            // than hard coding it.
            $compiler->compile($out, $class);

            // Close the file so it's flushed to disk.
            fclose($out);

            // Now include it.  If there's a parse error PHP will
            // throw a ParseError and PHPUnit will catch it for us.
            include($filename);

            // If we got here, there's no parse error. So we know
            //  we can instantiate it.
            $generated = new $class();
        }
        finally {
            // Clean up the file, even if a ParseError is
            // thrown above.
            // The OS may be lazy about cleaning up after us, so
            //  it's polite to do so.
            unlink($filename);
        }


        // Now assert various things on $generated as appropriate.
        $this->assertEquals('A', $generated->thingThatReturnsA());
    }
}

What's great about this approach is how lightweight it is. I don't know if it's the most robust approach ever, but it is robust enough for my purposes. It also means that in actual usage we need only open a for-reals file handle and pass that to the compile() method. As any good test-driven approach does, this design gives us a very clean and maintainable API. Our compiler class can now work in any framework with any filesystem structure. It doesn't care as long as it gets a writeable stream.

So there you have it; a simple way to test code compilation directly with no 3rd party dependencies, and it won't break on simple formatting changes in the generated code.

May you find it useful.

Code updated 24 June 2018 to better clean up after itself, based on feedback from Matthew O'Phinney and Sara Golemon.

Coin Marketplace

STEEM 0.21
TRX 0.25
JST 0.038
BTC 95691.61
ETH 3343.89
USDT 1.00
SBD 3.03