PHPUnitのdataProviderの中でGeneratorを使用したときに、どれくらいメモリが節約できるのか知りたくて調べてみました。
環境
PHP 7.3.9
PHPUnit 8.3.5
Generatorって何?
Generatorとは、PHP5.5系から追加された機能です。
foreachで配列を回すような処理で、配列の代わりにGeneratorを使用すると、メモリ内で配列全体を組み立てる必要がなくなりメモリの節約ができるようになります。
イメージしづらいと思うので、実例を見てみましょう。
まずはGeneratorを使わずに大きな配列を定義し、それをforeachで回してみます。
function dataByArray()
{
$data = [];
for ($i = 0; $i < 10000; $i++) {
$data[] = [str_repeat('あ', 100), str_repeat('あ', 200), str_repeat('あ', 300)];
}
return $data;
}
$maxMemory = 0;
foreach (dataByArray() as $data) {
$memory = memory_get_usage() / (1024 * 1024);
if ($maxMemory < $memory) {
$maxMemory = $memory;
}
}
echo $maxMemory . "MB\n";
for文のループ件数を変更しながら、メモリ使用量を見てみたところ、下記のようになりました。
件数 | メモリ使用量 |
---|---|
1000件 | 2.929328918457MB |
2000件 | 5.2722854614258MB |
3000件 | 7.6464920043945MB |
10000件 | 24.203437805176MB |
件数の増加とともにメモリ使用量が増加していることがわかります。
これをGeneratorを使用するように修正してみましょう。
function dataByGenerator()
{
for ($i = 0; $i < 10000; $i++) {
yield [str_repeat('あ', 100), str_repeat('あ', 200), str_repeat('あ', 300)];
}
}
$maxMemory = 0;
foreach (dataByGenerator() as $data) {
$memory = memory_get_usage() / (1024 * 1024);
if ($maxMemory < $memory) {
$maxMemory = $memory;
}
}
echo $maxMemory . "MB\n";
件数 | メモリ使用量 |
---|---|
1000件 | 0.58547973632812MB |
2000件 | 0.58547973632812MB |
3000件 | 0.58547973632812MB |
10000件 | 0.58547973632812MB |
なんと、件数を変更しても全くメモリ使用量が変化しませんでした!
これはつよい(小並感)。
配列を使用すると、一度全件分の配列(forが10000件なら要素が10000の配列)をメモリ上に作ります。
一方、Generatorを使用すると、全体をメモリに乗せることはせずにyield
を使用した行ごとにメモリを確保しているように見えます(本当のところは自信がない。。。)。
ともあれ、配列の代わりにGeneratorを使用するとメモリ使用量が節約できる、ということがわかりました。
PHPUnitのdataProviderって何?
dataProviderとは、PHPUnitのテストメソッドに任意の引数を渡す仕組みです。
2. PHPUnit 用のテストの書き方 - データプロバイダ — PHPUnit latest Manual
アサーションの内容を変更せずに、色々なパターンのデータをテストしたいときに使用することが多いです。
これも実例を見てみましょう。
<?php
use PHPUnit\Framework\TestCase;
class DataTest extends TestCase
{
/**
* ↓アノテーションでテストメソッドに引数を渡すためのデータプロバイダを指定します
* @dataProvider additionProvider
*/
public function testAdd($a, $b, $expected)
{
$this->assertSame($expected, $a + $b);
}
// ↓データプロバイダは配列で指定します
public function additionProvider()
{
return [
[0, 0, 0],
[0, 1, 1],
[1, 0, 1],
[1, 1, 3]
];
}
}
テストを実行すると、データプロバイダの件数分(このテストでは4件)のテストが実行されます。
$ phpunit DataTest
PHPUnit |version|.0 by Sebastian Bergmann and contributors.
...F
Time: 0 seconds, Memory: 5.75Mb
There was 1 failure:
1) DataTest::testAdd with data set #3 (1, 1, 3)
Failed asserting that 2 is identical to 3.
/home/sb/DataTest.php:9
FAILURES!
Tests: 4, Assertions: 4, Failures: 1.
dataProviderの詳しい解説は、こちらの記事が参考になりました!
dataProviderでGeneratorを使ってみると
ここからが本題です。
テストメソッドに任意の引数を渡すdataProviderを先ほどの例では配列で定義しましたが、Generatorで定義することもできます。
配列の代わりにGeneratorを使うと、メモリ使用量を節約できていい感じなのは先ほど見た通り。
という訳で、早速Generatorを使ってどれだけいい思いができるのか試してみます。
まずは単純に配列を使用したケースでメモリ使用量を測定します。
<?php
namespace Tests;
use PHPUnit\Framework\TestCase;
class ArrayTest extends TestCase
{
private static $maxMemory = 0;
/**
* @dataProvider arrayDataProvider
*/
public function testSample($a, $b, $c)
{
$memory = memory_get_usage() / (1024 * 1024);
if (self::$maxMemory < $memory) {
self::$maxMemory = $memory;
}
$this->assertTrue(mb_strlen($a) + mb_strlen($b) === mb_strlen($c));
}
public function arrayDataProvider()
{
$data = [];
for ($i = 0; $i < 10000; $i++) {
$data[] = [str_repeat('あ', 100), str_repeat('あ', 200), str_repeat('あ', 300)];
}
return $data;
}
public static function tearDownAfterClass(): void
{
parent::tearDownAfterClass();
echo "\n\n" . self::class . ": ";
echo self::$maxMemory . "MB\n\n";
}
}
ざっくり説明すると、self::$maxMemory
にメモリ使用量の最大値を保存し、テストケースクラスの最後のテストの実行後にコールされるtearDownAfterClass()
を使ってメモリ使用量を表示しています。
これを実行すると、「44.769813537598MB」という結果が得られました。
次にGeneratorを使用したテストを用意します。
<?php
namespace Tests;
use PHPUnit\Framework\TestCase;
class GeneratorTest extends TestCase
{
private static $maxMemory = 0;
/**
* @dataProvider generatorDataProvider
*/
public function testSample($a, $b, $c)
{
$memory = memory_get_usage() / (1024 * 1024);
if (self::$maxMemory < $memory) {
self::$maxMemory = $memory;
}
$this->assertTrue(mb_strlen($a) + mb_strlen($b) === mb_strlen($c));
}
public static function generatorProvider()
{
for ($i = 0; $i < 10000; $i++) {
yield [str_repeat('あ', 100), str_repeat('あ', 200), str_repeat('あ', 300)];
}
}
public static function tearDownAfterClass(): void
{
parent::tearDownAfterClass();
echo "\n\n" . self::class . ": ";
echo self::$maxMemory . "MB\n\n";
}
}
dataProviderの実装部分がGeneratorに切り替わってるだけで、テスト内容やメモリの表示方法は同じとなっています。
これをさっそく実行してみると、、、
44.769905090332MB
、、、あれ(;´・ω・)???
配列:44.769813537598MB
Generator:44.769905090332MB
全然ダメじゃん(/ω\)!!
先ほどまでのGeneratorの優秀さが嘘のよう。全くメモリ節約ができていませんでした。
なぜdataProviderとGeneratorを組み合わせるとメモリ節約ができていないのか
予想に反してGeneratorの力が全く発揮できていない事態に。
原因究明のためにPHPUnitのソースを覗いてみました。
vendor\phpunit\phpunit\src\Util\Test.php
のgetDataFromDataProviderAnnotation()
メソッドで
@dataProviderアノテーションを目印にデータプロバイダを探して引数として渡す値を取り出しているようです。
final class Test
{
...
private static function getDataFromDataProviderAnnotation(string $docComment, string $className, string $methodName): ?iterable
{
...
if ($dataProviderMethod->getNumberOfParameters() === 0) {
$data = $dataProviderMethod->invoke($object);
} else {
$data = $dataProviderMethod->invoke($object, $methodName);
}
if ($data instanceof \Traversable) {
$origData = $data;
$data = [];
// ※※※
foreach ($origData as $key => $value) {
if (\is_int($key)) {
$data[] = $value;
} elseif (\array_key_exists($key, $data)) {
throw new InvalidDataProviderException(
\sprintf(
'The key "%s" has already been defined in the data provider "%s".',
$key,
$match
)
);
} else {
$data[$key] = $value;
}
}
}
if (\is_array($data)) {
$result = \array_merge($result, $data);
}
}
return $result;
}
return null;
}
}
※マークを付けた箇所でデータプロバイダから受け取った値をループして、配列に詰めなおしています。
データプロバイダをGeneratorで実装していても、この部分で全件分の配列をメモリ上に確保しているため、メモリ使用量が配列で実装した場合と変わらなくなっているようです。
結論
- 配列の代わりにGeneratorを使用すると、foreachでループするときにメモリを節約できる
- しかし、PHPUnitのdataProviderではGeneratorを使用してもメモリが節約されない
意外な結果になりましたが、Generatorを使えばいつもメモリ節約につながるとは限らない、ということでした。
自分の目で確かめてみるということは大事ですね(小並感)。
もし、「この検証方法はおかしい」とかありましたら、優しい言葉でのツッコミをお待ちしています。