📧

PHP Fakerの safeEmail の中身を覗いてみた(重複エラーの回避方法もあるよ)

2023/10/16に公開

TL;DR

Fakerでつくられるemailアドレスは重複の可能性があるから、自前実装を検討しても良さそうでした!

目次

  • safeEmailの実装を覗くきっかけ
  • safeEmailの実装を紐解く
  • 何が良くなかったのか
  • 回避方法
  • まとめ

safeEmailの実装を覗くきっかけ

最近、CIのテストが DBの重複キーエラーで落ちていることがありました。

このエラーでテストを再実行して時間をロスすることが出てきたので、エラーを回避する方法を調べていました。

重複キーエラーとなっていたのは、Factoryで作られているUserのレコードのemailでした。

UserのFactoryは以下のような実装になっていました(実際には他のカラムなどもありますが省略しています)。

class UserFactory extends Factory
{
    public function definition(): array
    {
        return [
            'email' => $this->faker->unique()->safeEmail(),
        ];
    }
}

Faker から、uniqueを実行した後に safeEmailを呼び出しています。

なんか重複しなさそうに見えますね。が、実際重複エラーが出てしまっているので何か問題がありそうです。

このsafeEmailがどう作られているのかを紐解くところに鍵がありそうです。

Fakerの実装を紐解く

safeEmailって何やってるの?

FakerのsafeEmailを覗いてみると

final public function safeEmail()
{
    return preg_replace('/\s/u', '', $this->userName() . '@' . static::safeEmailDomain());
}

GitHubコード

ふむふむ、ユーザー名とドメインをくっつけて空白を削除している、と。

となると怪しいのはユーザー名を作っているところっぽいですね。

ユーザー名は、、、

public function userName()
{
    $format = static::randomElement(static::$userNameFormats);
    $username = static::bothify($this->generator->parse($format));

    $username = strtolower(static::transliterate($username));

    // check if transliterate() didn't support the language and removed all letters
    if (trim($username, '._') === '') {
        throw new \Exception('userName failed with the selected locale. Try a different locale or activate the "intl" PHP extension.');
    }

    // clean possible trailing dots from first/last names
    $username = str_replace('..', '.', $username);
    $username = rtrim($username, '.');

    return $username;
}

GitHubコード

なんか結構ゴニョゴニョやっていそう。

日本語にすると、

  • ユーザー名を作るフォーマットをランダムに選択して
  • フォーマットからユーザー名を作成して、特定文字列が含まれている場合にはランダム文字列に置き換えて
  • 作られたユーザー名の翻訳(特殊記号の変換)を行なって、小文字に変換し
  • ドット二つを一つにして、空白を削除

している感じでしょうか。

となると深ぼるところは

フォーマットからユーザー名を作成して、特定文字列が含まれている場合にはランダム文字列に置き換え

る処理のところですね。

ユーザー名を作るフォーマットはいくつか種類があるようです。

なので、まずはフォーマットで利用されるfirstNamelastName にどうやって値が入っているのかを見てみます。

それぞれの処理は parse という関数に書かれてるのですがちょっとわかりにくいですね。

public function parse($string)
{
    return preg_replace_callback('/\{\{\s?(\w+)\s?\}\}/u', array($this, 'callFormatWithMatches'), $string);
}

GitHubコード

ざっくり理解にはなりますが、

  • ダミーデータの元になるファイルが設定されている locale(※1)に従って呼び出され
  • そのファイルに定義されている firstNamelastName (※2)を取ってくる

みたいな処理になっていそうです。

※1:localeは、Unitテストを実行するアプリケーションの設定ファイル(Laravelだと config/app.php )に記載する faker_locale で設定されています

※2:弊社では en_US だったので、このファイルでした

(´-`).。oO(なるほど、ファイルに定義されている固定値をランダムで取ってきているだけなのか。)

ということがわかりました。

何が良くなかったのか

emailを作る仕組みがわかったところで、落ちているテストに戻ってみると・・・

ERROR:  duplicate key value violates unique constraint "users_email_unique"
DETAIL:  Key (email)=(rdaugherty@example.net) already exists.

というエラーでした。

どうやら、

?{{lastName}}

GitHubコード

のフォーマットで落ちている様子。

lastNameのパターンが 472個しかないので、まぁ重複も起こるよね、という感じでした。

回避方法

さて肝心の回避方法ですが、やり方はいろいろあると思います。

せっかくなのでFakerの元の実装をリスペクトしつつ以下のようにしてみました。

テスト用のemailなので、ドメインの部分のみ Fakerの safeEmailDomainにしてあげれば事故は防げます。

private function generateEmail(): string
    {
        $email = $this->faker->firstName();
        $email .= $this->faker->lastName();
        $email .= '+' . (string)$this->faker->numberBetween(1, 1000);
        $email .= '@';
        $email .= $this->faker->safeEmailDomain();
        return $email;
    }
}

firstNameとlastNameの組み合わせだけだと少し不安だったので、メールアドレスのエイリアスとしてランダムな数字を追加してみました。

これでひとまずエラーが発生しなくなったので、効果はあったようです。

ワークフロー全体の成功率で見ても明らかにエラーが減ったのでよかったです(一番最後の週が改善後)。

補足

ちなみに、Fakerには unique という便利そうな関数がありますが、インスタンスが異なる場合には uniqueにならないのであまり使えませんでした。

Fakerの UniqueGenerator を覗いてみるとuniqueの仕組みも理解できそうです。

まとめ

思っていたよりもダミー元データが少ないので、重複エラーが発生する箇所にはそのままは使いづらいなぁと思いました。

ちゃんとライブラリの中身を見に行くと理解が深まって良いですね。

Discussion