公開日 2026-09-13

Slim 4を手組み配線でテスト可能にする(DIコンテナなしでHTTPテストを書く)

Slim 4 を DI コンテナなしの手組み配線で起動し、本番とテストで同じ配線を通して DB なしの HTTP テストを書けるようにする。

目次

  1. 前提環境
  2. 1. 作るものと配線の全体像
  3. コンテナを入れない理由
  4. 2. デモ環境を作成して起動する
  5. 3. ドメインとリポジトリを用意する
  6. コードのポイント
  7. 4. アクションとJSONエラーを用意する
  8. コードのポイント
  9. 5. 手組み配線でアプリを組み立てる
  10. コードのポイント
  11. 6. curl で本番経路を確認する
  12. DBを止めても /health は 200 のままになる
  13. 7. DBなしでHTTPテストを書く
  14. コードのポイント
  15. 8. まとめ

対象読者: Slim 4 の基本ルーティングは触ったことがあり、DI コンテナを入れずにテストしやすい構成を組みたい方

この記事では、品目コードを 1 件照会する小さな JSON API を Slim 4 で作ります。DI コンテナ(PSR-11)は使わず、依存を手で組み立てる関数 createApp() を 1 つだけ置く構成です。本番ではこの関数が本物のリポジトリを生成し、テストでは同じ関数へ偽物のリポジトリを渡します。入口の引数が違うだけで、DB を立てなくてもアプリ全体の HTTP 応答を PHPUnit から検証できる、というのが狙いです。

確認は curl と PHPUnit の 2 本立てです。認証、複数テーブル、ORM、CI 連携は扱いません。題材は、実務で書いたハンディ端末向け API から配線とテストの要点だけを取り出し、品目照会という一般的な形へ置き換えたものです。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL連携有効)

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。アプリ用コンテナのサービス名は app、PostgreSQL は db に固定しておきます。

1. 作るものと配線の全体像

完成形は次の 4 点です。

  • GET /health が DB に触れずに {"status":"ok"} を返す
  • GET /api/items/{code} が PostgreSQL から品目を引き、JSON で応答する
  • 該当なしや存在しないパスは、HTML ではなく JSON エラーになる
  • 同じ createApp() に偽リポジトリを渡し、DB なしで上記の応答を PHPUnit から確認できる

今回の範囲外も先に切っておきます。

  • 認証・認可(前提にしない)
  • 一覧・登録・更新(GET 1 本に絞る)
  • ORM やマイグレーションツール(テーブルは SQL を直接流す)
  • GitHub Actions などの CI 連携

コンテナを入れない理由

DI コンテナを入れると、依存の組み立てがコンテナの定義ファイルへ移ります。小〜中規模の API では、その定義を読み解くより、依存を new で並べた 1 つの関数を見るほうが全体を追いやすいです。テストの差し替えも、コンテナの定義を上書きする代わりに、関数の引数へ偽実装を渡すだけで済みます。

依存が増えて共有インスタンスが多くなる規模なら、コンテナの自動解決が配線の手間を減らします。今回のように依存が数個で収まるうちは、手で並べたほうが見通しがよい、という判断です。その「1 つの関数」が createApp() にあたります。本番経路とテスト経路は、入口の引数が違うだけで、ルーティングもミドルウェアも同じ道を通ります。

flowchart LR
    subgraph prod["本番経路 public/index.php"]
        A1["createApp()"] --> A2["PdoItemRepository"]
        A2 --> A3[("PostgreSQL")]
    end
    subgraph test["テスト経路 PHPUnit"]
        B1["createApp(fake)"] --> B2["InMemoryItemRepository"]
    end
    A1 --> C["同じ Slim App / 同じ routes.php"]
    B1 --> C
    C --> D["JSON レスポンス"]

最終的なファイル構成は次の形です。

slim4-testable-manual-wiring-demo/
├─ app/
│  ├─ bootstrap.php   # createApp() を定義
│  ├─ routes.php      # ルート定義を返すクロージャ
│  └─ settings.php    # 環境変数から設定を組む
├─ public/
│  └─ index.php       # 本番の入口
├─ src/
│  ├─ Item.php
│  ├─ Action/
│  ├─ Database/
│  ├─ Handler/
│  └─ Repository/
├─ tests/
│  ├─ Fake/
│  ├─ Http/
│  └─ bootstrap.php
├─ docker/php/Dockerfile
├─ compose.yml
├─ composer.json
└─ phpunit.xml

2. デモ環境を作成して起動する

WSL 側のシェルで作業します。Windows 側から始める場合は、wsl で Ubuntu に入ってから実行してください。

次のコマンドで作業ディレクトリを用意します。

# Windows側から始める場合のみ実行
# wsl
mkdir -p ~/projects/slim4-testable-manual-wiring-demo
cd ~/projects/slim4-testable-manual-wiring-demo
mkdir -p docker/php app public src/Action src/Database src/Handler src/Repository tests/Fake tests/Http
code .

compose.yml を作成します。app には DB 接続値を environment で渡し、コードへの直書きを避けます。db 側の healthcheck は、初回接続エラーを抑えるためのものです。

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    working_dir: /workspace
    volumes:
      - ./:/workspace
    ports:
      - "8080:8080"
    environment:
      APP_ENV: dev
      DB_DSN: "pgsql:host=db;port=5432;dbname=app"
      DB_USER: app
      DB_PASSWORD: app
    command: ["sleep", "infinity"]
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 20

docker/php/Dockerfile を作成します。本物のリポジトリが PostgreSQL へ接続するため、拡張 pdo_pgsql が必要です。Composer がアーカイブを展開できるよう、unzip も入れておきます。

FROM php:8.5-cli

RUN apt-get update \
    && apt-get install -y --no-install-recommends unzip libpq-dev \
    && docker-php-ext-install pdo_pgsql \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /workspace

composer.json を作成します。本番コードは App\ 名前空間、テスト補助クラスは Tests\ 名前空間です。オートロードを 2 系統に分けておきます。

{
  "name": "example/slim4-testable-manual-wiring-demo",
  "type": "project",
  "require": {},
  "require-dev": {},
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  }
}

コンテナを起動し、依存パッケージを入れます。バージョンを固定しておくと、環境ごとの差分が少なくなり、再現しやすくなります。

docker compose up -d --build
docker compose exec app composer require slim/slim:^4.15 slim/psr7:^1.8
docker compose exec app composer require --dev phpunit/phpunit:^11.5

DB が起動したら、品目テーブルを作成して 2 件だけ投入します。psql をホストに入れていなくても、コンテナ内から実行できます。

docker compose exec -T db psql -U app -d app \
  -c "CREATE TABLE IF NOT EXISTS items (code TEXT PRIMARY KEY, name TEXT NOT NULL, unit_price INTEGER NOT NULL);"
docker compose exec -T db psql -U app -d app \
  -c "INSERT INTO items (code, name, unit_price) VALUES ('A-1001', 'M6 六角ボルト', 120), ('A-1002', 'M6 平ワッシャー', 18) ON CONFLICT (code) DO NOTHING;"

ここまでで下準備は完了です。PHP・Composer・PostgreSQL とテーブルがそろいました。HTTP サーバーは 6 章で手動起動します。

詰まり時:

  • pdo_pgsql のビルドで失敗する: libpq-dev の行が抜けていないか確認し、docker compose up -d --build をやり直す
  • psql が could not connect を返す: docker compose ps で db が healthy か確認してから再実行する

3. ドメインとリポジトリを用意する

最初に置くのは、品目を表す値オブジェクトと、取得口になるインターフェースです。本番もテストも、この ItemRepository 越しに品目へアクセスします。

src/Item.php を作成します。

<?php
declare(strict_types=1);

namespace App;

final readonly class Item
{
    public function __construct(
        public string $code,
        public string $name,
        public int $unitPrice,
    ) {
    }

    /**
     * @return array{code: string, name: string, unitPrice: int}
     */
    public function toArray(): array
    {
        return [
            'code' => $this->code,
            'name' => $this->name,
            'unitPrice' => $this->unitPrice,
        ];
    }
}

取得口は findByCode の 1 メソッドだけです。src/Repository/ItemRepository.php を作成します。

<?php
declare(strict_types=1);

namespace App\Repository;

use App\Item;

interface ItemRepository
{
    public function findByCode(string $code): ?Item;
}

次は、PDO 接続を作る係です。src/Database/ConnectionFactory.php を作成します。

<?php
declare(strict_types=1);

namespace App\Database;

use PDO;

final class ConnectionFactory
{
    private ?PDO $connection = null;

    /**
     * @param array{dsn: string, user: string, password: string} $config
     */
    public function __construct(private array $config)
    {
    }

    public function connect(): PDO
    {
        return $this->connection ??= $this->create();
    }

    private function create(): PDO
    {
        $pdo = new PDO(
            $this->config['dsn'],
            $this->config['user'],
            $this->config['password'],
        );
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

        return $pdo;
    }
}

コードのポイント

① 接続は connect() を最初に呼んだときだけ張る(ConnectionFactory.php)

    public function connect(): PDO
    {
        return $this->connection ??= $this->create();
    }

$this->connection ??= $this->create() は、connection が null のときだけ create() を呼び、結果を保持します。ConnectionFactory を組み立てた時点では、接続はまだ張られていない状態です。実際に SQL を投げる経路に入って初めて接続が走るので、DB を使わない GET /health は接続なしで応答できます(6 章で確認します)。

続いて、このファクトリ越しに SQL を投げる本物のリポジトリです。src/Repository/PdoItemRepository.php を作成します。

<?php
declare(strict_types=1);

namespace App\Repository;

use App\Database\ConnectionFactory;
use App\Item;

final class PdoItemRepository implements ItemRepository
{
    public function __construct(private ConnectionFactory $connectionFactory)
    {
    }

    public function findByCode(string $code): ?Item
    {
        $pdo = $this->connectionFactory->connect();

        $stmt = $pdo->prepare(
            'SELECT code, name, unit_price FROM items WHERE code = :code'
        );
        $stmt->execute(['code' => $code]);

        $row = $stmt->fetch();
        if ($row === false) {
            return null;
        }

        return new Item($row['code'], $row['name'], (int) $row['unit_price']);
    }
}

4. アクションとJSONエラーを用意する

HTTP の入口になるアクションを 2 つ作ります。HealthAction は DB に触れず、ShowItemAction はリポジトリだけに依存する 2 本です。

src/Action/HealthAction.php を作成します。

<?php
declare(strict_types=1);

namespace App\Action;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final class HealthAction
{
    public function __invoke(
        ServerRequestInterface $request,
        ResponseInterface $response,
    ): ResponseInterface {
        $response->getBody()->write(
            (string) json_encode(['status' => 'ok'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)
        );

        return $response->withHeader('Content-Type', 'application/json');
    }
}

src/Action/ShowItemAction.php を作成します。該当する品目がなければ HttpNotFoundException を投げ、404 への変換はエラーハンドラに任せる流れです。

<?php
declare(strict_types=1);

namespace App\Action;

use App\Repository\ItemRepository;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Exception\HttpNotFoundException;

final class ShowItemAction
{
    public function __construct(private ItemRepository $items)
    {
    }

    /**
     * @param array{code?: string} $args
     */
    public function __invoke(
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $args,
    ): ResponseInterface {
        $code = (string) ($args['code'] ?? '');
        $item = $this->items->findByCode($code);

        if ($item === null) {
            throw new HttpNotFoundException($request, "Item not found: {$code}");
        }

        $response->getBody()->write(
            (string) json_encode($item->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)
        );

        return $response->withHeader('Content-Type', 'application/json');
    }
}

失敗時の応答を JSON へそろえるため、src/Handler/JsonErrorHandler.php を作成します。

<?php
declare(strict_types=1);

namespace App\Handler;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Slim\Psr7\Factory\ResponseFactory;
use Throwable;

final class JsonErrorHandler
{
    public function __construct(private ResponseFactory $responseFactory)
    {
    }

    public function __invoke(
        ServerRequestInterface $request,
        Throwable $exception,
        bool $displayErrorDetails,
        bool $logErrors,
        bool $logErrorDetails,
    ): ResponseInterface {
        $status = 500;
        $message = 'Internal Server Error';

        if ($exception instanceof HttpNotFoundException) {
            $status = 404;
            $message = $exception->getMessage();
        } elseif ($exception instanceof HttpBadRequestException) {
            $status = 400;
            $message = $exception->getMessage();
        } elseif ($exception instanceof HttpMethodNotAllowedException) {
            $status = 405;
            $message = 'Method Not Allowed';
        } elseif ($displayErrorDetails) {
            $message = $exception->getMessage();
        }

        $response = $this->responseFactory->createResponse($status);
        $response->getBody()->write(
            (string) json_encode(
                ['error' => ['status' => $status, 'message' => $message]],
                JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE
            )
        );

        return $response->withHeader('Content-Type', 'application/json');
    }
}

コードのポイント

① Slim の HTTP 例外はステータスへ、それ以外は 500 へ(JsonErrorHandler.php)

        if ($exception instanceof HttpNotFoundException) {
            $status = 404;
            $message = $exception->getMessage();
        } elseif ($exception instanceof HttpBadRequestException) {
            $status = 400;
            $message = $exception->getMessage();
        } elseif ($exception instanceof HttpMethodNotAllowedException) {
            $status = 405;
            $message = 'Method Not Allowed';
        } elseif ($displayErrorDetails) {
            $message = $exception->getMessage();
        }

ShowItemAction が投げる HttpNotFoundException は 404 に振り分けます。DB ダウン時の PDOException のように Slim の HTTP 例外でないものは、500 のままです。displayErrorDetails が true のときだけ詳細メッセージを足し、本番(false)では内部情報を伏せます。

5. 手組み配線でアプリを組み立てる

ここがこの記事の中心です。設定の読み込み、createApp() での配線、ルート定義の 3 ファイルを置きます。

app/settings.php を作成します。接続値とエラー表示の方針は、環境変数から読み込む形です。

<?php
declare(strict_types=1);

$appEnv = getenv('APP_ENV') ?: 'dev';

return [
    'displayErrorDetails' => $appEnv !== 'prod',
    'logErrors' => true,
    'db' => [
        'dsn' => getenv('DB_DSN') ?: 'pgsql:host=db;port=5432;dbname=app',
        'user' => getenv('DB_USER') ?: 'app',
        'password' => getenv('DB_PASSWORD') ?: 'app',
    ],
];

app/bootstrap.php を作成します。依存を手で組み立て、組み上がった App を返す関数を 1 つ置くだけです。

<?php
declare(strict_types=1);

use App\Action\HealthAction;
use App\Action\ShowItemAction;
use App\Database\ConnectionFactory;
use App\Handler\JsonErrorHandler;
use App\Repository\ItemRepository;
use App\Repository\PdoItemRepository;
use Slim\App;
use Slim\Factory\AppFactory;
use Slim\Psr7\Factory\ResponseFactory;

function createApp(?ItemRepository $itemRepository = null): App
{
    /** @var array{displayErrorDetails: bool, logErrors: bool, db: array{dsn: string, user: string, password: string}} $settings */
    $settings = require __DIR__ . '/settings.php';

    $app = AppFactory::create();

    $app->addRoutingMiddleware();

    $errorMiddleware = $app->addErrorMiddleware(
        $settings['displayErrorDetails'],
        $settings['logErrors'],
        false,
    );
    $errorMiddleware->setDefaultErrorHandler(
        new JsonErrorHandler(new ResponseFactory())
    );

    $itemRepository ??= new PdoItemRepository(
        new ConnectionFactory($settings['db'])
    );

    $health = new HealthAction();
    $showItem = new ShowItemAction($itemRepository);

    (require __DIR__ . '/routes.php')($app, $health, $showItem);

    return $app;
}

app/routes.php を作成します。App と組み立て済みのアクションを受け取り、URL とハンドラの対応だけを書く構成です。

<?php
declare(strict_types=1);

use App\Action\HealthAction;
use App\Action\ShowItemAction;
use Slim\App;

return static function (
    App $app,
    HealthAction $health,
    ShowItemAction $showItem,
): void {
    $app->get('/health', $health);
    $app->get('/api/items/{code}', $showItem);
};

public/index.php を作成します。本番の入口は、オートロードと配線を読み込み、createApp() を実行するだけの薄い 1 枚です。

<?php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../app/bootstrap.php';

createApp()->run();

コードのポイント

① AppFactory::create() でコンテナなしに起動する(bootstrap.php)

    $app = AppFactory::create();

AppFactory::create() は、slim/psr7 があれば PSR-17 ファクトリを自動検出します。コンテナを接続する createFromContainer() は今回使いません。依存はこのあと手で並べるので、コンテナ定義ファイルは不要です。

② 引数が空なら本番実装、渡されたらそれを使う(bootstrap.php)

    $itemRepository ??= new PdoItemRepository(
        new ConnectionFactory($settings['db'])
    );

$itemRepository ??= ... が、本番経路とテスト経路の分岐点です。引数なしで呼ぶと本物の PdoItemRepository を組み立て、偽実装を渡して呼ぶとそちらを使います。偽実装を渡したときは ConnectionFactory も new されないので、テスト経路は PDO に一切触れない構成です。

③ ルートにはクラス名ではなく実体を渡す(routes.php)

    $app->get('/health', $health);
    $app->get('/api/items/{code}', $showItem);

コンテナがある構成では、ShowItemAction::class のようにクラス名を渡し、解決をコンテナへ任せます。コンテナを持たない今回は、5 章で new 済みのアクション実体をそのまま渡します。Slim は呼び出し可能オブジェクトをそのまま実行するので、依存つきのアクションでも追加の解決は不要です。

6. curl で本番経路を確認する

別ターミナルを開き、HTTP サーバーを手動起動します。このコマンドは起動したままになるので、止めるまでそのターミナルは閉じないでください。

docker compose exec app php -S 0.0.0.0:8080 -t public public/index.php

最初に GET /health を確認します。

curl -i http://localhost:8080/health

実行結果例:

HTTP/1.1 200 OK
Content-Type: application/json

{"status":"ok"}

次に GET /api/items/{code} を、登録済みコードと未登録コードの両方で叩いてください。

curl -i http://localhost:8080/api/items/A-1001
curl -i http://localhost:8080/api/items/NOPE

実行結果例:

HTTP/1.1 200 OK
Content-Type: application/json

{"code":"A-1001","name":"M6 六角ボルト","unitPrice":120}
HTTP/1.1 404 Not Found
Content-Type: application/json

{"error":{"status":404,"message":"Item not found: NOPE"}}

DBを止めても /health は 200 のままになる

遅延接続の挙動を確かめます。DB だけ止めて、docker compose stop db を実行してから 2 本を比べてください。

docker compose stop db
curl -i http://localhost:8080/health
curl -i http://localhost:8080/api/items/A-1001

GET /health はリポジトリに触れないため、接続が張られず 200 のまま返ります。GET /api/items/{code} はリポジトリが接続を試みるので、PDOException から 500 応答です。

HTTP/1.1 200 OK
Content-Type: application/json

{"status":"ok"}
HTTP/1.1 500 Internal Server Error
Content-Type: application/json

{"error":{"status":500,"message":"..."}}

message には、displayErrorDetails が true の開発時のみ SQLSTATE を含む接続エラーが入ります。本番(false)では Internal Server Error です。

/health を「プロセスが生きているか」、品目取得を「DB まで届くか」と読み替えると、監視の対象を切り分けられます。ロードバランサの死活監視は /health だけを見ればよく、DB の一時不通でアプリ全体が落ちたとは扱われません。

確認が済んだら DB を戻してください。次のテストは DB を使わないので、止めたままでも進められます。

docker compose start db

詰まり時:

  • /api/items/A-1001 が 404 になる: 2 章のテーブル作成と投入が済んでいるか確認する
  • 例外時に HTML が返る: bootstrap.php の setDefaultErrorHandler() を呼べているか見直す

7. DBなしでHTTPテストを書く

ここで、手組み配線にしておいた利点が出ます。createApp() に偽リポジトリを渡し、アプリ全体を起動して HTTP 応答を検証する流れです。DB は使いません。

テスト起動時にもオートロードと配線を読み込むため、tests/bootstrap.php を作成します。

<?php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../app/bootstrap.php';

テストの実行設定は最小限です。phpunit.xml を作成します。

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         bootstrap="tests/bootstrap.php"
         colors="true">
    <testsuites>
        <testsuite name="app">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

続いて ItemRepository の偽実装です。tests/Fake/InMemoryItemRepository.php を作成します。

<?php
declare(strict_types=1);

namespace Tests\Fake;

use App\Item;
use App\Repository\ItemRepository;

final class InMemoryItemRepository implements ItemRepository
{
    /** @var array<string, Item> */
    private array $items = [];

    /**
     * @param list<Item> $items
     */
    public function __construct(array $items = [])
    {
        foreach ($items as $item) {
            $this->items[$item->code] = $item;
        }
    }

    public function findByCode(string $code): ?Item
    {
        return $this->items[$code] ?? null;
    }
}

いよいよ、アプリ全体を起動して応答を確かめるテストです。tests/Http/ShowItemTest.php を作成します。

<?php
declare(strict_types=1);

namespace Tests\Http;

use App\Item;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
use Tests\Fake\InMemoryItemRepository;

final class ShowItemTest extends TestCase
{
    public function testReturnsItemAsJson(): void
    {
        $repository = new InMemoryItemRepository([
            new Item('A-1001', 'M6 六角ボルト', 120),
        ]);
        $app = createApp($repository);

        $request = (new ServerRequestFactory())
            ->createServerRequest('GET', '/api/items/A-1001');
        $response = $app->handle($request);

        self::assertSame(200, $response->getStatusCode());

        /** @var array{code: string, name: string, unitPrice: int} $payload */
        $payload = json_decode((string) $response->getBody(), true);
        self::assertSame('A-1001', $payload['code']);
        self::assertSame('M6 六角ボルト', $payload['name']);
        self::assertSame(120, $payload['unitPrice']);
    }

    public function testReturns404WhenItemMissing(): void
    {
        $app = createApp(new InMemoryItemRepository());

        $request = (new ServerRequestFactory())
            ->createServerRequest('GET', '/api/items/NOPE');
        $response = $app->handle($request);

        self::assertSame(404, $response->getStatusCode());

        /** @var array{error: array{status: int}} $payload */
        $payload = json_decode((string) $response->getBody(), true);
        self::assertSame(404, $payload['error']['status']);
    }
}

テストを実行します。

docker compose exec app ./vendor/bin/phpunit

実行結果例:

PHPUnit 11.5 by Sebastian Bergmann and contributors.

..                                                                  2 / 2 (100%)

Time: 00:00.0xx, Memory: xx.xx MB

OK (2 tests, 6 assertions)

コードのポイント

① テストも本番と同じ配線を通す(ShowItemTest.php)

        $app = createApp($repository);

        $response = $app->handle($request);

createApp($repository) は、5 章で本番が呼ぶ関数そのものです。違いは、偽リポジトリを渡す 1 点だけです。$app->handle($request) はルーティング・アクション・エラーハンドラを順に通すので、ルート定義や 404 の振る舞いまで含めて 1 つのテストで確認できます。createApp() は名前空間を持たない関数のため、Tests\Http の中からも修飾なしで呼べます。

② DB を一切踏まない(ShowItemTest.php)

        $repository = new InMemoryItemRepository([

偽実装を渡すと、createApp() 内の PdoItemRepository と ConnectionFactory は組み立てられません。PostgreSQL を起動していなくてもテストは通り、CI でも DB コンテナを用意せずに HTTP 層まで確認できる構成です。

8. まとめ

この記事で組み立てた構成は、次のとおりです。

  • DI コンテナを持たず、依存を手で並べる createApp() を 1 つの配線口にする
  • routes.php はクロージャを返し、new 済みのアクション実体を受け取る
  • ConnectionFactory の遅延接続で、GET /health を DB 非依存に保つ
  • 同じ createApp() に偽リポジトリを渡し、DB なしで HTTP 応答を PHPUnit から検証する

起動できないときは 5 章へ、curl の応答が合わないときは 6 章へ、テストが落ちるときは 7 章へ戻ると切り分けやすくなります。

POST を足すなら、addBodyParsingMiddleware() を配線に加え、バリデーションエラーも同じ JSON 形へそろえると後続の API を増やしやすくなります。アクションが増えてきたら、routes.php へ個別に渡す代わりに、ハンドラをまとめて渡す形へ寄せていくとよいでしょう。

DI コンテナを使う構成と読み比べたい場合は、同じシリーズの Slim 4で最小JSON APIを作る(routing + DI + error handling) が対になります。

シリーズ 4/7

このシリーズ

Slim 4で作るPHP API

  1. 1. Slim Framework 4 を使った業務Webアプリ導入ガイド(PHP 8.2+)
  2. 2. Slim 4で最小JSON APIを作る(routing + DI + error handling)
  3. 3. Slim 4 + PostgreSQLで認証付きREST APIを作る(JWT最小構成)
  4. 4. Slim 4を手組み配線でテスト可能にする(DIコンテナなしでHTTPテストを書く) 現在の記事
  5. 5. Slim 4の例外レスポンスをJSONに統一する(ErrorHandler継承とAPP_ENV出し分け)
  6. 6. 選択肢UIに頼らないSlim APIの入力検証(UseCaseで存在チェックして400に変換)
  7. 7. Slim 4のAPI DTOをfinal readonly classで不変にする(名前付き引数・match・JSON_THROW_ON_ERROR)