Тестирование файловой системы с vfsStream

Если вы заботитесь о качестве своего проекта и кода, то пишете unit-тесты. Но с ними всегда есть «особые случаи». Один из них — работа с файловой системой и ресурсами. Решение «в лоб» — параллельно создавать папку/дерево специально для тестов и надеяться что они не прыгнут на настоящие пути и ничего не удалят.

Более правильный подход — использование in-memory виртуальной файловой системы, vfs. А поскольку ресурсы это по сути потоки, то и название для этой мок-библиотеки — vfsStream. Ставим..

composer install mikey179/vfsStream

Теперь инициализируем vfs. Заметьте что все функции оперируют в контексте своего корня (root). Если вы будете создавать виртуальные папки без его указания, то и в дереве они не появятся.

public function setUp() {
    //vfsStream::setup();
    //vfsStreamWrapper::register();
    //vfsStreamWrapper::setRoot(vfsStream::newDirectory('root', 0777));
    //$this->rootDir = vfsStreamWrapper::getRoot();
    $this->rootDir = vfsStream::setup('root', 0777);
}

Дерево

Теперь в самом тесте надо создать заготовку, изначальное состояние дерева..

vfsStream::create([
    'library' => [
        'bb2075d7d7023ebd5929f6a3f4c4d499' => [
            'original.jpg'=>'erferf',
            'size' => [
                '160.jpg',
                '320.jpg'
                ]
            ]
        ]
    ],
    $this->rootDir
);
unlink(vfsStream::url('root/library/bb2075d7d7023ebd5929f6a3f4c4d499/size/160.jpg'));
#PHPUnit_Framework_Error_Warning : unlink(vfs://root/library/bb2075d7d7023ebd5929f6a3f4c4d499/size/160.jpg): No such file or directory

Если бы мы попробовали сразу же удалить файл.. но получили бы ошибку. Всё дело в том что vfs эмулирует и права и пользователей. Просто так файл удалить не получится — код его не достучиться своим пользователем. Из этого следует и вторая проблема с быстрым синтаксисом создания дерева — в нём не указываются права по умолчанию.

Самое простое решение — создать всё дерево и потом вручную создать файл с правами 777.

vfsStream::newFile('original.jpg', 0777)->setContent('test')->at(
    $this->rootDir->getChild('library')->getChild('bb2075d7d7023ebd5929f6a3f4c4d499')
    //$this->rootDir->getChildByPath('library/bb2075d7d7023ebd5929f6a3f4c4d499')
);

Аналогично есть метод newDirectory для создания папок. Из-за ограничений прав, возникает проблема — как дерево увидеть. Для этого созданы классы *Visitor. Например можно получить дерево в виде ассоциативного массива..

$result = vfsStream::inspect(new \org\bovigo\vfs\visitor\vfsStreamStructureVisitor())->getStructure();
$this->assertEquals([
    'root' => [
        'library' => [
            'f420b5caa94fb3ac74fe4fb602e38fe8' => []
        ]
    ]
], $result);

Я в свою очередь сделал класс который распечатывает дерево в виде строки, возможно станет доступным скоро

\=root @777
.\=library @777
..\=f420b5caa94fb3ac74fe4fb602e38fe8 @755

Тесты

Основная проблема — пути. Библиотечка vfsStream не поддерживает chdir() и realpath()
Но хуже всего то, что все пути надо обёртывать на такой вот формат, внутри самого кода..

vfsStream::url('root/test.txt'); //вида vfs://root/test.txt
Это значит что вы не можете работать с относительными путями, т.е. код вида
unlink("./readme.txt");

Должен обёртываться с помощью какой-то функции. Как следствие — вы не можете соединять части путей (concat), т.к. как только заинжектите путь из теста, получите

PHPUnit_Framework_Error : Object of class orgbovigovfsStreamDirectory could not be converted to string

Как это я обхожу? Во-первых приходится менять код, добавляя обёртку. Это не очень красиво, но в какой-то мере получается абстакция от путей. Для обычного кода возвращается сам путь, для тестов — я могу внедрить свою функцию-трансформер путей

public function removeSizeDir($path){
    if (is_dir($this->fullPath($path . '/size/'))) {
        rmdir($this->fullPath($path . '/size/'));
    }
}
public $pathRewrite = false;
/**
 * Wrap all filesystem access to change path in one place
 * Used actively with unit tests to wrap absolute paths to virtual file system
 *
 * @param string $path
 * @return string
 */
public function fullPath($path){
    if(is_callable($this->pathRewrite)){
        $tmp = $this->pathRewrite;
        return $tmp($path);
    }
    else{
        return $path;
    }
}

Обёртка путей ставится один раз в setUp и дальше проблем уже нет..

public function setUp() {
//..
    $this->o              = new myObjectUnderTest($this->rootDir);
    $this->o->pathRewrite = function ($path) {
        return vfsStream::url('root/' . $path);
    };
}
/**
 * @test
 */
public function removeSizeDir(){
    vfsStream::create([
            'library' => [
                'bb2075d7d7023ebd5929f6a3f4c4d499' => [
                    'size' => []
                ]
            ]
        ], $this->rootDir
    );
    $this->o->removeSizeDir('library/bb2075d7d7023ebd5929f6a3f4c4d499');
    $result   = vfsStream::inspect(new \org\bovigo\vfs\visitor\vfsStreamAssertVisitor())->getStructure();
        $expected = <<<EOF
\=root @777
.\=library @777
..\=bb2075d7d7023ebd5929f6a3f4c4d499 @755
EOF;
        $this->assertEquals($expected, $result, $result);
}
Больших и качественных вам покрытий кода ?