PHPでTDDをしながら簡単なRSS生成アプリを作ってみた
こんにちは。Nonです。
今回は事情によりRSS用のXMLを作成するライブラリをTDDしながら作成したみた話をしたいと思います。
TDDについて
以前関連する記事をかいていました。これをより実践的にやってみようという話になりますね。
以前の記事はこちら。
TDDしながらコードを書いてみる
TDDはRED→Green→Refactoringが基本ですので、まずはテストコードを失敗させるところからスタートですね。
<?php
declare(strict_types=1);
namespace Tests;
use SimpleRssMaker\SimpleRssMaker;
use PHPUnit\Framework\TestCase;
class SimpleRssMakerTest extends TestCase
{
    public function test__construct()
    {
        $simpleRssMaker = new SimpleRssMaker();
        $this->assertInstanceOf(SimpleRssMaker::class, $simpleRssMaker);
        return $simpleRssMaker;
    }
    /**
     * @depends test__construct
     * @param SimpleRssMaker $simpleRssMaker
     */
    public function testRss2(SimpleRssMaker $simpleRssMaker)
    {
        $rss2 = $simpleRssMaker->rss2();
        $this->assertEquals("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n", $rss2);
    }
}
<?php
declare(strict_types=1);
namespace SimpleRssMaker;
class SimpleRssMaker implements SimpleRssMakerInterface
{
    public function rss2(): string
    {
        return "";
    }
}
空文字を返す処理と理想的な<?xml version="1.0" encoding="UTF-8"?>\nを確認するテストを用意してテストを実行します。
.F 2 / 2 (100%)
結果はもちろん失敗です。
こちらを成功させるためにリファクタリングしましょう。
<?php
declare(strict_types=1);
namespace SimpleRssMaker;
class SimpleRssMaker implements SimpleRssMakerInterface
{
    public function rss2(): string
    {
        return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
    }
}
空文字を返すのではなく、理想的な<?xml version="1.0" encoding="UTF-8"?>\nを返すようにリファクタリングしました。
.. 2 / 2 (100%)
結果はもちろん成功です。しかしこれでは、XMLタグを作成したとは言えないですね。思いっきりマジックナンバーになってしまっています。
<?php
declare(strict_types=1);
namespace SimpleRssMaker;
class SimpleRssMaker implements SimpleRssMakerInterface
{
    public function rss2(): string
    {
        $dom = new \DOMDocument('1.0', 'UTF-8');
        $xml = $dom->saveXml();
        return (string)$xml;
    }
}
XMLタグを動的に生成してくれるように変更しました。
.. 2 / 2 (100%)
うまくいきましたね。
しかしこれではRSSを作成したとは言えないので、引き続きテストを修正します。
    /**
     * @depends test__construct
     * @param SimpleRssMaker $simpleRssMaker
     */
    public function testRss2(SimpleRssMaker $simpleRssMaker)
    {
        $rss2 = $simpleRssMaker->rss2();
        $expected = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"/>
XML;
        $this->assertEquals((string)$expected, (string)$rss2);
    }
RSSに必要な<rss>タグを期待するテストへ変更しました。
ついでに、ヒアドキュメントを利用し、ファイル入力文字としてで期待するように変数を修正しました。
.F 2 / 2 (100%)
当然テスト失敗します。
次はこのテストをクリアするようにコードを修正しましょう。
<?php
declare(strict_types=1);
namespace SimpleRssMaker;
class SimpleRssMaker implements SimpleRssMakerInterface
{
    public function rss2(): string
    {
        $dom = new \DOMDocument('1.0', 'UTF-8');
        $rss = $dom->createElement('rss');
        $dom->appendChild($rss);
        $xml = $dom->saveXml();
        $rss = new \SimpleXmlElement($xml);
        $rss->addAttribute('version', '2.0');
        $xml = $rss->asXML();
        return (string)$xml;
    }
}
PHPのDOMDocumentクラスとSimpleXmlElementを利用して、XMLタグとRSSタグを作成するコードを作成しました。
.. 2 / 2 (100%)
テストもクリアしたので、問題なさそうです。
次は<rss>タグの中身がなければRSSとして機能しませんので、期待値を変更します。
    /**
     * @depends test__construct
     * @param SimpleRssMaker $simpleRssMaker
     */
    public function testRss2(SimpleRssMaker $simpleRssMaker)
    {
        $rss2 = $simpleRssMaker->rss2();
        $expected = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <language>ja</language>
  </channel>
</rss>
XML;
        $this->assertEquals((string)$expected, (string)$rss2);
    }
RSS2.0の必須項目である<channnel>タグを用意し、その中に<language>タグを期待するテストへ変更しました。
.F 2 / 2 (100%)
テストはもちろん失敗です。
    public function rss2(): string
    {
        $dom = new \DOMDocument('1.0', 'UTF-8');
        $rss = $dom->createElement('rss');
        $dom->appendChild($rss);
        $xml = $dom->saveXML();
        $rss = new \SimpleXMLElement($xml);
        $rss->addAttribute('version', '2.0');
        $channel = $rss->addChild('channel');
        $channel->addChild('language', 'ja');
        $dom = dom_import_simplexml($rss)->ownerDocument;
        $dom->formatOutput = true;
        $xml = $dom->saveXML();
        return (string)$xml;
    }
必要タグとその値をXMLに追加する処理を追加し、テストします。
.. 2 / 2 (100%)
クリアできたので、コードに問題はありませんね。
    /**
     * @depends test__construct
     * @param SimpleRssMaker $simpleRssMaker
     */
    public function testRss2(SimpleRssMaker $simpleRssMaker)
    {
        $rss2 = $simpleRssMaker->rss2();
        $expectedDate = \DateTime::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00', new \DateTimeZone('UTC'));
        $expected = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>タイトル</title>
    <link>https://example.com</link>
    <description>説明</description>
    <language>ja</language>
    <copyright>コピーライト</copyright>
    <pubDate>{$expectedDate->format(\DateTimeInterface::RFC822)}</pubDate>
    <category>カテゴリ</category>
  </channel>
</rss>
XML;
        $this->assertEquals((string)$expected, (string)$rss2);
    }
では本格的にRSSの期待値を変更します。
.F 2 / 2 (100%)
    public function rss2(): string
    {
        $dom = new \DOMDocument('1.0', 'UTF-8');
        $rss = $dom->createElement('rss');
        $dom->appendChild($rss);
        $xml = $dom->saveXML();
        $rss = new \SimpleXMLElement($xml);
        $rss->addAttribute('version', '2.0');
        $channel = $rss->addChild('channel');
        $channel->addChild('title', 'タイトル');
        $channel->addChild('link', 'https://example.com');
        $channel->addChild('description', '説明');
        $channel->addChild('language', 'ja');
        $channel->addChild('copyright', 'コピーライト');
        $channel->addChild('pubDate', 'Wed, 01 Jan 20 00:00:00 +0000');
        $channel->addChild('category', 'カテゴリ');
        $dom = dom_import_simplexml($rss)->ownerDocument;
        $dom->formatOutput = true;
        $xml = $dom->saveXML();
        return (string)$xml;
    }
.. 2 / 2 (100%)
かなり直列なコードですが、テストがクリアしたのでOKです。
しかしこれでは、動的に作成することができませんので、更に修正します。
<?php
declare(strict_types=1);
namespace Tests;
use SimpleRssMaker\SimpleRssMaker;
use PHPUnit\Framework\TestCase;
class SimpleRssMakerTest extends TestCase
{
    public function test__construct()
    {
        $simpleRssMaker = new SimpleRssMaker();
        $this->assertInstanceOf(SimpleRssMaker::class, $simpleRssMaker);
        return $simpleRssMaker;
    }
    /**
     * @depends test__construct
     * @param SimpleRssMaker $simpleRssMaker
     */
    public function testRss2(SimpleRssMaker $simpleRssMaker)
    {
        $rss2 = $simpleRssMaker->rss2();
        $expectedDate = \DateTime::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00', new \DateTimeZone('UTC'));
        $expected = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>title</title>
    <link>https://example.com</link>
    <description>description</description>
    <language>ja</language>
    <copyright>copyright</copyright>
    <pubDate>Wed, 01 Jan 20 00:00:00 +0000</pubDate>
    <category>category</category>
    <image>
      <url>https://example.com/sample.png</url>
      <title>title</title>
      <link>https://example.com</link>
    </image>
  </channel>
</rss>
XML;
        $this->assertEquals((string)$expected, (string)$rss2);
    }
}
期待値を微調整し、欲しいタグを少しだけ追加しました。
.F 2 / 2 (100%)
テストは失敗しましたね、先程、動的に値を取得作成していきたいと言ったのでそろそろコードも本格的にしていきましょう。
<?php
declare(strict_types=1);
namespace SimpleRssMaker;
use SimpleRssMaker\Rss2\Command\UseCases\CreateRss2;
use SimpleRssMaker\Rss2\Command\UseCases\CreateRss2Input;
use SimpleRssMaker\Rss2\Command\UseCases\CreateRss2Output;
use SimpleRssMaker\Rss2\Models\Entities\Rss2;
class SimpleRssMaker implements SimpleRssMakerInterface
{
    public function rss2(): string
    {
        $input = new CreateRss2Input();
        $output = new CreateRss2Output();
        $useCase = new CreateRss2();
        $useCase->process($input, $output);
        $rss2dom = $output->rss2();
        return (string)$rss2dom;
    }
}
final class CreateRss2Input implements CreateRss2InputPort
{
}
final class CreateRss2Output implements CreateRss2OutputPort
{
    private Rss2 $rss2;
    public function output(Rss2 $rss2): void
    {
        $this->rss2 = $rss2;
    }
    public function rss2(): Rss2
    {
        return $this->rss2;
    }
}
final class CreateRss2 implements CreateRss2Interface
{
    public function process(CreateRss2InputPort $inputPort, CreateRss2OutputPort $outputPort): void
    {
        $outputPort->output(new Rss2());
    }
}
<?php
declare(strict_types=1);
namespace SimpleRssMaker\Rss2\Models\Entities;
use DOMDocument;
use SimpleRssMaker\Shared\Models\ValueObjects\XmlEncoding;
use SimpleRssMaker\Shared\Models\ValueObjects\XmlVersion;
use SimpleXMLElement;
final class Rss2
{
    public function createXMLDOM(): DOMDocument
    {
        $dom = new DOMDocument((string)$this->xmlVersion, (string)$this->xmlEncoding);
        $rss = $dom->createElement('rss');
        $dom->appendChild($rss);
        return $dom;
    }
    public function createSimpleXmlElement(): SimpleXMLElement
    {
        $xml = $this->createXMLDOM()->saveXML();
        $rss = new SimpleXMLElement($xml);
        $rss->addAttribute('version', '2.0');
        $channel = $rss->addChild('channel');
        $channel->addChild('title', 'title');
        $channel->addChild('link', 'https://example.com');
        $channel->addChild('description', 'description');
        $channel->addChild('language', 'ja');
        $channel->addChild('copyright', 'copyright');
        $channel->addChild('pubDate', 'Wed, 01 Jan 20 00:00:00 +0000');
        $channel->addChild('category', 'category');
        $image = $channel->addChild('image');
        $image->addChild('url', 'https://example.com/sample.png');
        $image->addChild('title', 'title');
        $image->addChild('link', 'https://example.com');
        return $rss;
    }
    public function __toString(): string
    {
        $dom = dom_import_simplexml($this->createSimpleXmlElement())->ownerDocument;
        $dom->formatOutput = true;
        return $dom->saveXML();
    }
}
とりあえずユースケースクラスとエンティティを作成し、その中で固定のXMLを返す処理にしてみました。
先程までSimpleRssMakerに書いていた処理をそのままエンティティに書き直しただけですね。
.. 2 / 2 (100%)
テストはクリアしたので、構造に問題はなさそうです。
では次に値を動的に生成できるようにしていきましょう。
結構かっちり作成したいので、ValueObejctを作成します。
ValueObejctを作成するからにはこの子のテストを書かなければなりませんね。
最初と同様に期待するテストと失敗する処理を書いてからのスタートです。
<?php
declare(strict_types=1);
namespace Tests\Rss2\Models\ValueObjects;
use SimpleRssMaker\Rss2\Models\ValueObjects\Link;
use PHPUnit\Framework\TestCase;
use Tests\TestHelper\StrTestHelper;
class LinkTest extends TestCase
{
    public function test__construct()
    {
        $expected = StrTestHelper::createRandomUrl(Link::MAX_LENGTH);
        $link = new Link($expected);
        $this->assertInstanceOf(Link::class, $link);
        $this->assertEquals($expected, (string)$link);
        return $link;
    }
    public function testFormatException()
    {
        $this->expectException(LinkFormatException::class);
        $expected = StrTestHelper::createRandomStr();
        new Link($expected);
    }
    public function testLengthException()
    {
        $this->expectException(StrLengthException::class);
        $expected = StrTestHelper::createRandomUrl(Link::MAX_LENGTH + 1);
        new Link($expected);
    }
}
期待するテストを書きました。
実装したコードはこちら。
<?php
declare(strict_types=1);
namespace SimpleRssMaker\Rss2\Models\ValueObjects;
final class Link
{
    private string $link;
    public function __construct(string $link)
    {
        $this->link = $link;
    }
    public function __toString(): string
    {
        return (string)$this->link;
    }
}
There were 2 failures:
1) Tests\Rss2\Models\ValueObjects\LinkTest::testFormatException
Failed asserting that exception of type "SimpleRssMaker\Shared\Exceptions\LinkFormatException" is thrown.
2) Tests\Rss2\Models\ValueObjects\LinkTest::testLengthException
Failed asserting that exception of type "SimpleRssMaker\Shared\Exceptions\StrLengthException" is thrown.
クラスのインスタンス型のテストはクリアしていますが、バリデーション関連のテストで失敗していますね。
処理を書き直します。
<?php
declare(strict_types=1);
namespace SimpleRssMaker\Rss2\Models\ValueObjects;
use SimpleRssMaker\Foundation\Helpers\Rules\UriRule;
use SimpleRssMaker\Shared\Exceptions\LinkFormatException;
use SimpleRssMaker\Shared\Exceptions\StrLengthException;
final class Link
{
    /**
     * Maximum character length of URLs when using IE
     */
    public const MAX_LENGTH = 2083;
    private string $link;
    public function __construct(string $link)
    {
        if (!UriRule::isValidHttp($link)) {
            throw new LinkFormatException(sprintf('%s must start at https:// or http://', get_class()));
        }
        $this->link = $link;
    }
    public function __toString(): string
    {
        return (string)$this->link;
    }
}
httpかhttps://から始まるリンクでないと検証失敗するコードを追加しました。
There was 1 failure:
1) Tests\Rss2\Models\ValueObjects\LinkTest::testLengthException
Failed asserting that exception of type "SimpleRssMaker\Shared\Exceptions\StrLengthException" is thrown.
テストのエラーが一つ減ったのでうまく行ったようです。
<?php
declare(strict_types=1);
namespace SimpleRssMaker\Rss2\Models\ValueObjects;
use SimpleRssMaker\Foundation\Helpers\Rules\UriRule;
use SimpleRssMaker\Shared\Exceptions\LinkFormatException;
use SimpleRssMaker\Shared\Exceptions\StrLengthException;
final class Link
{
    /**
     * Maximum character length of URLs when using IE
     */
    public const MAX_LENGTH = 2083;
    private string $link;
    public function __construct(string $link)
    {
        if (!UriRule::isValidHttp($link)) {
            throw new LinkFormatException(sprintf('%s must start at https:// or http://', get_class()));
        }
        if (mb_strlen($link) > self::MAX_LENGTH) {
            throw new StrLengthException(sprintf('%s must be less than %s chars.', get_class(), self::MAX_LENGTH));
        }
        $this->link = $link;
    }
    public function __toString(): string
    {
        return (string)$this->link;
    }
}
続いて、最大文字長の検証コードを追加しました。
.. 2 / 2 (100%)
これでLinkのValueObejctは感性しましたね。
public function test__construct()
{
    $expected = StrTestHelper::createRandomUrl(Link::MAX_LENGTH);
    $link = new Link($expected);
    $this->assertInstanceOf(Link::class, $link);
    $this->assertEquals($expected, (string)$link);
    return $link;
}
このテストでValueObjectとしての機能をテストして、他のExceptionテストで検証のテストがクリアしています。
こんな感じでTDDは進めるといいかも
TDDの強み
このように、画面でデバッグを進めるのではなく、テストで開発を進めるのがTDDです。
テストは当然プログラムで動作しているので、プログラム上のエラーも拾ってくれるので、非常に進めやすいです。また、自分の期待する値をあらかじめテストに記載してやれば、正常に動作したときの動作確認もできて一石二鳥だということがわかるかと思います。
最終的にはこんな感じ
最終的なメインクラスのテストコードはこんな感じになりました。
(仕様的にはまだ満たしていなけどひとまず雰囲気だけでも感じてください)
<?php
declare(strict_types=1);
namespace Tests;
use DateTime;
use DateTimeZone;
use Exception;
use SimpleRssMaker\Rss2\Models\Entities\Channel;
use SimpleRssMaker\Shared\Exceptions\ChannelNotExistException;
use SimpleRssMaker\SimpleRssMaker;
use PHPUnit\Framework\TestCase;
use SimpleRssMaker\SimpleRssMakerInterface;
use Tests\TestHelper\StrTestHelper;
class SimpleRssMakerTest extends TestCase
{
    public function test__construct()
    {
        $simpleRSSMaker = new SimpleRssMaker();
        $this->assertInstanceOf(SimpleRssMaker::class, $simpleRSSMaker);
        return $simpleRSSMaker;
    }
    /**
     * @depends test__construct
     * @param SimpleRssMakerInterface $simpleRSSMaker
     */
    public function testException(SimpleRssMakerInterface $simpleRSSMaker)
    {
        $this->expectException(ChannelNotExistException::class);
        $simpleRSSMaker->rss2();
    }
    /**
     * @depends test__construct
     * @param SimpleRssMakerInterface $simpleRSSMaker
     */
    public function testChannelFactory(SimpleRssMakerInterface $simpleRSSMaker)
    {
        $channel = $simpleRSSMaker->channelFactory(
            StrTestHelper::createRandomStr(),
            StrTestHelper::createRandomUrl(),
            StrTestHelper::createRandomStr(),
        );
        $this->assertInstanceOf(Channel::class, $channel);
        $simpleRSSMaker->setChannel($channel);
        $this->assertIsString($simpleRSSMaker->rss2());
    }
    /**
     * @depends test__construct
     * @param SimpleRssMakerInterface $simpleRSSMaker
     * @throws Exception
     */
    public function testRss2(SimpleRssMakerInterface $simpleRSSMaker)
    {
        $title = StrTestHelper::createRandomStr();
        $link = StrTestHelper::createRandomUrl();
        $description = StrTestHelper::createRandomStr();
        $copyright = StrTestHelper::createRandomStr();
        $pubDate = new DateTime('now', new DateTimeZone('UTC'));
        $category = StrTestHelper::createRandomStr();
        $channel = $simpleRSSMaker
            ->channelFactory($title, $link, $description);
        $channel->setCopyright($copyright);
        $channel->setPubDate($pubDate);
        $channel->setCategory($category);
        $rss2 = $simpleRSSMaker
            ->setChannel($channel)
            ->rss2();
        $expected = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>{$title}</title>
    <link>{$link}</link>
    <description>{$description}</description>
    <language>ja</language>
    <copyright>{$copyright}</copyright>
    <pubDate>{$pubDate->format(DateTime::RFC822)}</pubDate>
    <category>{$category}</category>
  </channel>
</rss>
XML;
        $this->assertEquals((string)$expected, (string)$rss2);
    }
}
最後に
こちらがGithubのコードです。
機能的にはまだまだ途中なので、引き続き経過を記事にしたいと思います。
その時はよしなに。
.