【PHP】interfaceを利用してテストコードを書く

@suin さんにコメントを頂いています。
大変参考になりますので是非コメントも合わせて読んでください。
https://qiita.com/tkek321/items/5803e7affc256de7a735#comments

# DB接続やファイルの削除(unlink)をしているクラスにテストを書きたかった

アップロードしたファイルをディレクトリに保存し、
ファイル名をDBに保存してあるシステムがあるとします。

DBからファイル名を削除すると、ディレクトリの中からもファイルが消えて欲しい。

### DBからファイル名を配列で取得し、unlink関数を利用してディレクトリ内のファイルを削除しているクラス

DB: table

|id |filename  |
|---|---|
|1  |hogehoge.img  |
|2  |foobar.img  |

ディレクトリ構成

```
src/
    img/
       hogehoge.img
       foobar.img
    FileRemover.php
```

```php:FilesRemover.php
<?php

/**
 * 現在使用している画像ファイルがDBに登録されている。
 * DBからファイル名を取得して使用していないファイルを削除する。
 */
class FilesRemover
{
   private const DIR_PATH = __DIR__ . '/img';
   private $pdo;

   public function __construct(PDO $pdo)
   {
       $this->pdo = $pdo;
   }
 
   public function remove(): void
   {
       $this->removeFiles($this->getNotRemoveFilePaths());
   }

  /**
   *  配列に含まれていないファイルを削除する
   */
   private function removeFiles(array $array): void
   {
        $filePaths = glob($this->dirPath.'/*');
        foreach ($filePaths as $filePath) {
            $fileName = basename($filePath);
            if (in_array($fileName, $notRemoveFiles, true) === false) {
                unlink(self::DIR_PATH . '/' . $fileName);
            }
        }
   }

   /**
    * DBから使用されているファイル名を取得
    */
   private function getNotRemoveFilePaths(): array
   {
       $stmt = $this->pdo->query('SELECT file_name FROM table');
       $results = $stmt->fetchAll(\PDO::FETCH_ASSOC);
       $fileNames = [];

       foreach ($results as $result) {
           $fileNames[] = $result['file_name'];
       }

       return $fileNames;
   }
}
```

このままではテストしづらいのでinterfaceを利用してテストコードを書けるようにしていきたい。

# インターフェースに依存するコードに書き直す

まずはテストが書きづらい、DBに接続しているコードとunlink関数を使用しているコードをインターフェースに依存する形に書き直します。

```php:Filesystem.php
<?php

namespace App;

interface Filesystem
{
    public function remove(string $fileName): void;
    public function getFileNames(): array;
}
```

```php:FilesRemover.php
<?php

namespace App;

class FilesRemover
{
    private $filesystem;
    private $dirPath;

    /**
     * Filesystemインターフェースを外部から注入
     * Filesystemの関数を利用してファイル名の取得、削除を行う
     */
    public function __construct(Filesystem $filesystem, string $dirPath = __DIR__ . '/img')
    {
        $this->filesystem = $filesystem;
        $this->dirPath = $dirPath;
    }

    public function remove(): void
    {
        $this->removeFiles($this->filesystem->getFileNames());
    }

    private function removeFiles(array $notRemoveFiles): void
    {
        $filePaths = glob($this->dirPath.'/*');
        foreach ($filePaths as $filePath) {
            $fileName = basename($filePath);
            if (in_array($fileName, $notRemoveFiles, true) === false) {
                $this->filesystem->remove($this->dirPath . '/' . $fileName);
            }
        }
    }
}
```

```php:RealFilesystem
<?php

namespace App;

class RealFilesystem implements Filesystem
{
    private $pdo;

    public function __construct(\PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    public function remove(string $fileName): void
    {
        unlink($fileName);
    }

    public function getFileNames(): array
    {
        $stmt = $this->pdo->query('SELECT file_name FROM foo');
        $results = $stmt->fetchAll(\PDO::FETCH_ASSOC);
        $fileNames = [];

        foreach ($results as $result) {
            $fileNames[] = $result['file_name'];
        }

        return $fileNames;
    }
}
```

# インターフェースを利用してテストを書く（インターフェースを継承したMockクラスを作成する）

ディレクトリ構成

```
src/
    img/
    FileRemover.php
    Filesystem.php
    RealFilesystem.php

tests/
    mock-img/
        mock-file-a.txt
        mock-file-b.txt
        mock-file-del-a.txt
        mock-file-del-b.txt
    FileRemoveTest.php
    MockFilesystem.php
```

テスト用に`mock-img`ディレクトリとファイルを作成する。

```php:MockFilesystem.php
<?php

use App\Filesystem;

/**
 * テスト用のMockクラス
 */
class MockFilesystem implements Filesystem
{
    private $removedFiles = [];

    /**
     * ファイルの削除ではなく実際のコードではunlink関数に渡されるファイル名を
     * 配列として保存している。
     * つまり、配列に保存されるファイル名が本来であれば削除されるファイル名。
     */
    public function remove(string $name): void
    {
        $this->removedFiles[] = $name;
    }

    /**
     * 単純に削除しない予定のファイル名の配列を返すだけの関数。
     */
    public function getFileNames(): array
    {
        return [
            'mock-file-a.txt',
            'mock-file-b.txt',
        ];
    }

    /**
     * 保存したファイル名の配列を取得するための関数。
     */
    public function getRemoveFiles()
    {
        return $this->removedFiles;
    }
}
```


```php:FilesRemoverTest
<?php

require_once __DIR__.'/../vendor/autoload.php';
require_once __DIR__.'/MockFilesystem.php';

use PHPUnit\Framework\TestCase;

class FilesRemoverTest extends TestCase
{
    /**
     * @test
     */
    public function 配列に含まれていないファイルを削除する()
    {
        // MockFilesystemをFilesRemoverに渡す
        $filesystem = new MockFilesystem();
        $remover = new App\FilesRemover($filesystem, __DIR__.'/mock-img');
        $remover->remove();

        // MockFilesystem::remove()で保存したファイル名を確認する
        $this->assertSame(
            [
                __DIR__.'/mock-img/mock-file-del-a.txt',
                __DIR__.'/mock-img/mock-file-del-b.txt'
            ],
            $filesystem->getRemoveFiles()
        );
    }
}

```

テストがかけた。