今回は、amazonで「テスト駆動開発」と「実践テスト駆動開発」の2冊をカートに入れるテストプログラムを作ります。(ログインはせず、購入手続きまで進みませんので、心配はいりません)
PuPHPeteerとは
WebアプリケーションのE2Eテストフレームワークの一つに、Puppeteer(パペッティア)があります。Puppeteerはブラウザを起動し、ブラウザを操作してE2Eテストをします。
PuPHPeteerは、PHPからPuppeteerを使うためのライブラリです。
前回までのテスト
前回は、amazonで「テスト駆動開発」をカートに入れるテストプログラムを作りました。
今回のテスト
今回は、amazon.co.jpで「テスト駆動開発(3080円)」と「実践テスト駆動開発(4620円)」をカートに入れて、合計7700円であることをテストします。
リファクタ
まず、前回のテストプログラムです。
<?php
namespace Ninton\Test;
use Ninton\Test\Libs\Amazon\AddedToCartPage;
use Ninton\Test\Libs\Amazon\DetailPage;
use Ninton\Test\Libs\Amazon\SearchResultPage;
use Ninton\Test\Libs\Amazon\TopPage;
class AmazonTest extends BasePuppeteerTestCase
{
public function test_テスト駆動開発を購入(): void
{
$topPage = TopPage::goto($this->page);
$this->screenShot();
$topPage->type検索ワード('テスト駆動開発');
$topPage->click検索();
$this->waitForPageLoad();
$this->screenShot();
$searchResultPage = new SearchResultPage($this->page);
$searchResultPage->click最初のサムネイル画像();
$detailPage = new DetailPage($this->page);
$detailPage->waitForカートに入れるボタン();
$this->screenShot();
$detailPage->clickカートに入れるボタン();
$addedToCartPage = new AddedToCartPage($this->page);
$addedToCartPage->waitForレジに進むボタン();
$this->screenShot();
$subTotal = $addedToCartPage->getカートの小計();
$this->assertEquals('3080', $subTotal);
}
}
Code language: plaintext (plaintext)
本のタイトルで検索して、カートに入れて、小計をassertする処理は、1冊めも2冊めもほぼ同じです。本のタイトル、検証する価格が違うだけです。
テストメソッドを次のように書きたいところです。
// 目標
public function test_2冊を購入()
{
$this->本を検索してカートに入れる('テスト駆動開発');
$this->assertカート追加直後の小計('3080');
$this->本を検索してカートに入れる('実践テスト駆動開発');
$this->assertカート追加直後の小計('7700');
}
Code language: plaintext (plaintext)
まず、前回のテストプログラムをリファクタして、2つのメソッドに分けます。
新しくAmazon2Test.phpを作成して、本のタイトルで検索してカートに入れる処理、カートの小計のassertの2つをメソッドに分けます。
<?php
namespace Ninton\Test;
use Ninton\Test\Libs\Amazon\AddedToCartPage;
use Ninton\Test\Libs\Amazon\DetailPage;
use Ninton\Test\Libs\Amazon\SearchResultPage;
use Ninton\Test\Libs\Amazon\TopPage;
class Amazon2Test extends BasePuppeteerTestCase
{
private function 本を検索してカートに入れる(string $title): void
{
$topPage = TopPage::goto($this->page);
$this->screenShot();
$topPage->type検索ワード($title);
$topPage->click検索();
$this->waitForPageLoad();
$this->screenShot();
$searchResultPage = new SearchResultPage($this->page);
$searchResultPage->click最初のサムネイル画像();
$detailPage = new DetailPage($this->page);
$detailPage->waitForカートに入れるボタン();
$this->screenShot();
$detailPage->clickカートに入れるボタン();
$addedToCartPage = new AddedToCartPage($this->page);
$addedToCartPage->waitForレジに進むボタン();
$this->screenShot();
}
private function assertカート追加直後の小計(string $expectedSubTotal): void
{
$addedToCartPage = new AddedToCartPage($this->page);
$subTotal = $addedToCartPage->getカートの小計();
$this->assertEquals($expectedSubTotal, $subTotal);
}
Code language: plaintext (plaintext)
リファクタ後のテストメソッドは次のようになります。
class Amazon2Test extends BasePuppeteerTestCase
{
public function test_2冊を購入(): void
{
$this->本を検索してカートに入れる('テスト駆動開発');
$this->assertカート追加直後の小計('3080');
}
private function 本を検索してカートに入れる(string $title): void
{
...
}
private function assertカート追加直後の小計(string $expectedSubTotal): void
{
...
}
Code language: plaintext (plaintext)

何をしているのか、わかりやすいね!
テストを実行して、合格することを確認します。16秒かかりました。
$ ./phpunit.sh tests/Amazon2Test.php
+ ./vendor/bin/phpunit --configuration=phpunit.xml tests/Amazon2Test.php
PHPUnit 8.5.14 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 15.88 seconds, Memory: 6.00 MB
OK (1 test, 1 assertion)
Code language: Bash (bash)
2冊目をカートに入れる
次に「実践テスト駆動開発」を検索して、カートに入れると「7700円」であることを確認します。
すでにメソッドがあるので、2行を追加するだけです。
public function test_2冊を購入(): void
{
$this->本を検索してカートに入れる('テスト駆動開発');
$this->assertカート追加直後の小計('3080');
$this->本を検索してカートに入れる('実践テスト駆動開発');
$this->assertカート追加直後の小計('7700');
}
Code language: plaintext (plaintext)
テストを実行して、合格することを確認します。40秒かかりました。
$ ./phpunit.sh tests/Amazon2Test.php
+ ./vendor/bin/phpunit --configuration=phpunit.xml tests/Amazon2Test.php
PHPUnit 8.5.14 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 39.67 seconds, Memory: 6.00 MB
OK (1 test, 2 assertions)
Code language: Bash (bash)
シナリオメソッド
さきほど小分けした「本を検索してカートに入れる」メソッドや、「assertカート追加直後の小計」メソッドを便宜上シナリオメソッドと呼ぶことにします。
シナリオメソッドは、ページクラスのclickやtype、getなどのメソッドを記述したもので、1ページから数ページの操作をまとめたメソッドです。
さきほどの「本を検索してカートに入れる」メソッドは、数ページにまたがる操作ですが、次のように、1ページごとのシナリオメソッドに分けることができます。
private function 本を検索してカートに入れる(string $title): void
{
$this->トップページ_表示する();
$this->トップページ_検索する($title);
$this->検索結果_最初のサムネイル画像をクリックする();
$this->商品詳細_カートに入れるボタンをクリックする();
$this->カート追加直後_レジに進むボタンを待つ();
}
private function トップページ_表示する(): void
{
TopPage::goto($this->page);
$this->screenShot();
}
private function トップページ_検索する(string $title): void
{
$topPage = new TopPage($this->page);
$topPage->type検索ワード($title);
$topPage->click検索();
$this->waitForPageLoad();
$this->screenShot();
}
private function 検索結果_最初のサムネイル画像をクリックする(): void
{
$searchResultPage = new SearchResultPage($this->page);
$searchResultPage->click最初のサムネイル画像();
}
private function 商品詳細_カートに入れるボタンをクリックする(): void
{
$detailPage = new DetailPage($this->page);
$detailPage->waitForカートに入れるボタン();
$this->screenShot();
$detailPage->clickカートに入れるボタン();
}
private function カート追加直後_レジに進むボタンを待つ(): void
{
$addedToCartPage = new AddedToCartPage($this->page);
$addedToCartPage->waitForレジに進むボタン();
$this->screenShot();
}
Code language: plaintext (plaintext)
Amazon2Test.php
https://github.com/ninton/study_puphpeteer/blob/main/tests/Amazon2Test.php
ところが、テストクラス内で小分けしたシナリオメソッドは、他のテストクラスから再利用することができません。

せっかくメソッドに小分けしたのに〜
シナリオクラス
解決のアイデアの一つは、シナリオメソッドを集めたシナリオクラスを作ることです。
class SearchScenario
{
public function 本を検索してカートに入れる(string $title)
{
}
public function assertカート追加直後の小計(string $expectedSubTotal)
{
}
}
Code language: plaintext (plaintext)
テストメソッドは次のように書くことになります。
public function test_2冊を購入(): void
{
$scenario = new SearchScenario($this->page);
$scenario->本を検索してカートに入れる('テスト駆動開発');
$scenario->assertカート追加直後の小計('3080');
}
Code language: plaintext (plaintext)
シナリオクラスのメリットは、複数のシナリオクラスで同じメソッド名を使うことができます。
デメリットは、使う前の new で、コードがごちゃごちゃした感じになることです。
また、テストメソッドは上から下へ流れるだけのもののほうが読みやすくメンテしやすいのですが、「クラス」があることで、難しくリファクタしそうなことも心配です。

テストが不合格になったとき、プログラムのデバグやメンテが難しかったら、そのテストは捨てられちゃうわよ
シナリオトレイト
もう一つのアイデアは、シナリオメソッドを集めた シナリオトレイトを作ることです。
トレイトはコードを再利用する仕組みです。トレイトは単なるメソッド群であり、インスタンスを作成できません。クラスがトレイトをuseすると、クラスにトレイトのメソッドが増設されます。一番の特徴は、1つのクラスで複数のトレイトをuseできることです。
trait SearchScenario
{
private function 本を検索してカートに入れる(string $title)
{
}
private function assertカート追加直後の小計(string $expectedSubTotal)
{
}
}
Code language: plaintext (plaintext)
テストメソッドは次のように書きます。
「本を検索してカートに入れる」メソッドや「assertカート追加直後の小計」メソッドを使うことができます。トレイトはメソッド専用の require 的な働きをします。
use Ninton\Test\Libs\Amazon\SearchScenario;
class Amazon2Test extends BasePuppeteerTestCase
{
use SearchScenario;
public function test_2冊を購入(): void
{
$this->本を検索してカートに入れる('テスト駆動開発');
$this->assertカート追加直後の小計('3080');
}
Code language: plaintext (plaintext)
トレイトのメリットは、メソッドの羅列だけの素朴な表現となるので、コードが読みやすいことです。
Selenium IDEのコマンド列のフラットな構造に似ています。
デメリットは、複数のトレイトで同じメソッド名を使えないことです。メソッド名をユニークにするために、サイト名やページ名を含めたほうがいいので、メソッド名は長くなりがちです。
ここでは、シナリオトレイトを選びます。
シナリオトレイトでリファクタ
tests/Libs/Amazon/SearchScenario.phpを新規作成します。
Amazon2Test.phpの小分けしたシナリオメソッドをそのままコピペしてきました。
<?php
namespace Ninton\Test\Libs\Amazon;
trait SearchScenario
{
private function 本を検索してカートに入れる(string $title): void
{
$this->トップページ_表示する();
$this->トップページ_検索する($title);
$this->検索結果_最初のサムネイル画像をクリックする();
$this->商品詳細_カートに入れるボタンをクリックする();
$this->カート追加直後_レジに進むボタンを待つ();
}
private function assertカート追加直後の小計(string $expectedSubTotal): void
{
$addedToCartPage = new AddedToCartPage($this->page);
$subTotal = $addedToCartPage->getカートの小計();
$this->assertEquals($expectedSubTotal, $subTotal);
}
private function トップページ_表示する(): void
{
TopPage::goto($this->page);
$this->screenShot();
}
private function トップページ_検索する(string $title): void
{
$topPage = new TopPage($this->page);
$topPage->type検索ワード($title);
$topPage->click検索();
$this->waitForPageLoad();
$this->screenShot();
}
private function 検索結果_最初のサムネイル画像をクリックする(): void
{
$searchResultPage = new SearchResultPage($this->page);
$searchResultPage->click最初のサムネイル画像();
}
private function 商品詳細_カートに入れるボタンをクリックする(): void
{
$detailPage = new DetailPage($this->page);
$detailPage->waitForカートに入れるボタン();
$this->screenShot();
$detailPage->clickカートに入れるボタン();
}
private function カート追加直後_レジに進むボタンを待つ(): void
{
$addedToCartPage = new AddedToCartPage($this->page);
$addedToCartPage->waitForレジに進むボタン();
$this->screenShot();
}
}
Code language: plaintext (plaintext)

すべてprivateだから不思議な感じだね
tests/Amazon3Test.phpを新規作成します。
traitを使うための宣言をします。
テストメソッドは、Amazon2Test.phpのテストメソッドと同じです。
<?php
namespace Ninton\Test;
use Ninton\Test\Libs\Amazon\SearchScenario;
class Amazon2Test extends BasePuppeteerTestCase
{
use SearchScenario;
public function test_2冊を購入(): void
{
$this->本を検索してカートに入れる('テスト駆動開発');
$this->assertカート追加直後の小計('3080');
$this->本を検索してカートに入れる('実践テスト駆動開発');
$this->assertカート追加直後の小計('7700');
}
}
Code language: plaintext (plaintext)
元のAmazon2Test.phpを分割しただけで、シナリオトレイトができました。そして、このシナリオトレイトは、他のテストクラスからも使うことができます。
まとめ
各ページごとのページクラス、シナリオメソッドを集めたシナリオトレイトが揃ってくると、短時間で、新しいテストケースを書けるようになってきます。
テスト対象のアプリケーションでは多くのクラスやメソッドを作ります。その多くは一箇所からしか呼んでいない、つまり1回しか利用していないとも言えます。(それを悪いと言っているのではなく)
シナリオトレイトのメソッドは、いろいろなテストケースで使います。何回も再利用しているんですね。
早くテストケースを書けるようになること、再利用している感があることで、アプリケーションコードの開発とはまた違う充実感があります。
tests/Amazon3Test.php
https://github.com/ninton/study_puphpeteer/blob/main/tests/Amazon3Test.php
tests/Libs/Amaozn/SearchScenario.php
https://github.com/ninton/study_puphpeteer/blob/main/tests/Libs/Amazon/SearchScenario.php