このエントリーをはてなブックマークに追加

CloudFormation、便利ですよね。インフラのコード化はインフラの状態の変化を可視化できるというたいへん素晴らしいメリットがあります。くわえてCloudFormationはスタック単位で作成・削除ができるため、WEBコンソールから手動で作成・削除するときに起こりうる「不要なリソースが残ったまま」や「必要なリソースを削除してしまう」というリスクが減ることで心理的・金銭的なコストが軽減される点がわたしは気に入っています。

さて当エントリでは「CloudFormationを使ってEC2インスタンスを立ち上げる」という基本的な作業において気になったUbuntuの起動システムについて調査した経過を記します。

前提

本番環境で稼働しているUbuntuベースのインスタンス(以後「稼働インスタンス」と呼びます)が存在します。この稼働インスタンスと同等のインスタンス(以後「テストインスタンス」と呼びます)を作成し、あるテストをおこないたいと思います。 そこで、稼働インスタンスのAMIをWEBコンソールから作成し、このAMIをもとにテストインスタンスをCloudFormationから作成することにしました。この際、稼働インスタンスでは各種デーモンが動いているのですがテストインスタンスにおいてはこれらを動かしたくありません。

どうする?

CloudFormationには各種ヘルパースクリプトが用意されています。

ヘルパースクリプトは Amazon Linux AMI の最新バージョンにプレインストールされています。他の UNIX/Linux AMI で使用するために、Amazon Linux yum リポジトリから入手することもできます。

http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/cfn-helper-scripts-reference.html

これらはAmazon Linux AMIですとプレインストールされていますがUbuntuベースのAMIですと意図的にインストールしてやる必要があります。 このヘルパースクリプトのうちcfn-initというスクリプトを使えばテストインスタンス起動時のデーモンの起動を無効にすることができそうです。

cfn-init ヘルパースクリプトは、AWS::CloudFormation::Init キーからテンプレートメタデータを読み取り、それに応じて次のような操作を行います。

  • CloudFormation のメタデータの取得と解析
  • パッケージのインストール
  • ディスクへのファイルの書き込み
  • サービスの有効化/無効化と開始/停止

http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/cfn-init.html

具体的にはAWS::CloudFormation::Initキーにおいてservicesキーを定義することでサービスの起動設定を行えるようです。 このとき、

Linux システムでは、このキーは sysvinit を使用してサポートされています。

http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-init.html#aws-resource-init-services

とありますが、ここで sysvinit とはなにを意味するのか?よくわかりませんので調べてみました。

sysvinit とは?

sysvinitはSystemV initの略で,UNIX SystemV(システムファイブ)と呼ばれるAT&T社謹製の古典的なUNIXが採用した起動メカニズムと同じ動作をするように設計されたソフトウェアです。

http://gihyo.jp/dev/serial/01/sc-literacy/0013

とありました。さらに調べるとこの仕組みはUbuntuにおいてはUbuntu 6.10 (Edgy Eft)においてUpstart に、 Ubuntu 15.04 (Vivid Vervet)においてsystemd に取って代わられたとのことです。 ということは、Ubuntuにおける直近のLTSリリースである14.0416.04sysvinitをサポートしていないのでしょうか?これではcfn-initは使えません。 とりあえずトライしてみることにしました。

cronデーモンを例にトライ

以下のようなテンプレートを用意しました。少々長いですが引用します。

 1 ---
 2 Description: sysvinit test
 3 Parameters:
 4   KeyName:
 5     Type: String
 6   VpcId:
 7     Type: String
 8   SubnetId:
 9     Type: String
10   Image:
11     Description: AMI
12     Type: String
13     AllowedValues:
14     - ami-8c4055eb # Ubuntu Server 14.04
15     - ami-785c491f # Ubuntu Server 16.04
16     Default: ami-785c491f # Ubuntu Server 16.04
17 Resources:
18   WebAccessGroup:
19     Type: AWS::EC2::SecurityGroup
20     Properties:
21       GroupDescription: Enable outgoing HTTP(S) access
22       SecurityGroupEgress:
23       - IpProtocol: tcp
24         FromPort: '80'
25         ToPort: '80'
26         CidrIp: 0.0.0.0/0
27       - IpProtocol: tcp
28         FromPort: '443'
29         ToPort: '443'
30         CidrIp: 0.0.0.0/0
31       VpcId: !Ref VpcId
32   SSHGroup:
33     Type: AWS::EC2::SecurityGroup
34     Properties:
35       GroupDescription: Enable global SSH access
36       SecurityGroupIngress:
37       - IpProtocol: tcp
38         CidrIp: 0.0.0.0/0
39         FromPort: 22
40         ToPort: 22
41       SecurityGroupEgress:
42       - IpProtocol: tcp
43         CidrIp: 0.0.0.0/0
44         FromPort: 22
45         ToPort: 22
46       VpcId: !Ref VpcId
47   TestServer:
48     Type: AWS::EC2::Instance
49     Properties:
50       ImageId: !Ref Image
51       InstanceType: t2.micro
52       KeyName: !Ref KeyName
53       AvailabilityZone: ap-northeast-1a
54       SubnetId: !Ref SubnetId
55       SecurityGroupIds:
56         - !Ref SSHGroup
57         - !Ref WebAccessGroup
58       UserData:
59         Fn::Base64:
60           Fn::Sub:
61           - |
62             #!/bin/bash -xe
63             apt-get update
64             apt-get -y install python-pip
65             pip install https://s3.amazonaws.com/cloudformation-examples/aws-cfn-bootstrap-latest.tar.gz
66             cp -a /usr/local/init/ubuntu/cfn-hup /etc/init.d/cfn-hup
67
68             chmod u+x /etc/init.d/cfn-hup
69             /usr/local/bin/cfn-init -v \
70                      --stack ${StackName} \
71                      --region ${Region} \
72                      --resource TestServer
73           - StackName: {Ref: "AWS::StackName"}
74             Region: {Ref: "AWS::Region"}
75     Metadata:
76       AWS::CloudFormation::Init:
77         config:
78           services:
79             sysvinit:
80               cron:
81                 enabled: 'false'
82                 ensureRunning: 'false'

このテンプレートにおいて今回の肝は58~74行目のUserDataキーと75行目以降のMetadataキーになります。 UserDataにてcfn-initヘルパースクリプトをインストールし、停止したいサービスを列挙したMetadataキー配下をcfn-init使用時に指定することで、テストインスタンス起動時にcronデーモンを停止することを目的としています。

たとえばcfn-initを作動させた状態でUbuntu 14.04テストインスタンスを立ち上げたいときはaws-cliを用いて以下のように実行することで試すことができます。ImageパラメータにUbuntu 14.04のAMIを指定していることに注意してください。

$ aws cloudformation create-stack \
    --stack-name cfn-init-test-ubuntu-14-cron-off \
    --template-body file:///path/to/cfn-init-test.template \
    --parameter ParameterKey=Image,ParameterValue=ami-8c4055eb \
                ParameterKey=KeyName,ParameterValue=key-name \
                ParameterKey=VpcId,ParameterValue=vpc-id \
                ParameterKey=SubnetId,ParameterValue=subnet-id

同様にcfn-initを作動させない状態でUbuntu 16.04テストインスタンスを立ち上げたいときは58行目以降を削除したのちに、

- 58       UserData:
- 59         Fn::Base64:
- 60           Fn::Sub:
- 61           - |
- 62             #!/bin/bash -xe
- 63             apt-get update
- 64             apt-get -y install python-pip
- 65             pip install https://s3.amazonaws.com/cloudformation-examples/aws-cfn-bootstrap-latest.tar.gz
- 66             cp -a /usr/local/init/ubuntu/cfn-hup /etc/init.d/cfn-hup
- 67
- 68             chmod u+x /etc/init.d/cfn-hup
- 69             /usr/local/bin/cfn-init -v \
- 70                      --stack ${StackName} \
- 71                      --region ${Region} \
- 72                      --resource TestServer
- 73           - StackName: {Ref: "AWS::StackName"}
- 74             Region: {Ref: "AWS::Region"}
- 75     Metadata:
- 76       AWS::CloudFormation::Init:
- 77         config:
- 78           services:
- 79             sysvinit:
- 80               cron:
- 81                 enabled: 'false'
- 82                 ensureRunning: 'false'

先述のようにaws-cliを用いて以下のように実行します。ImageパラメータにUbuntu 16.04のAMIを指定していることに注意してください。

$ aws cloudformation create-stack \
    --stack-name cfn-init-test-ubuntu-16-cron-off \
    --template-body file:///path/to/cfn-init-test.template \
    --parameter ParameterKey=Image,ParameterValue=ami-785c491f \
                ParameterKey=KeyName,ParameterValue=key-name \
                ParameterKey=VpcId,ParameterValue=vpc-id \
                ParameterKey=SubnetId,ParameterValue=subnet-id

ちなみにスタックが作成されるとWEBコンソールから以下のような表示が見られると思います。 cloudformation_

このように、各バージョンのUbuntuにおいてcfn-initの有効・無効を切り替えて試してみました。 結果、cfn-initを作動させた場合はcronデーモンは停止状態で、cfn-initを作動させない場合はcronデーモンは稼働状態でした。 これはUbuntu 14.04Ubuntu 16.04の双方で同様の現象でした。これらはsysvinitを採用していないはずなのになぜでしょう? まずは各々の実際に稼働した起動システムを調べてみます。

Ubuntu 14.04 の起動システム

ubuntu14$ sudo stat /proc/1/exe
  File: '/proc/1/exe' -> '/sbin/init'
  Size: 0               Blocks: 0          IO Block: 1024   symbolic link
Device: 3h/3d   Inode: 9323        Links: 1
Access: (0777/lrwxrwxrwx)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2017-06-28 07:43:10.904461000 +0000
Modify: 2017-06-28 06:03:36.184461000 +0000
Change: 2017-06-28 06:03:36.184461000 +0000
 Birth: -
ubuntu14$ /sbin/init --version
init (upstart 1.12.1)   # <= upstart
Copyright (C) 2006-2014 Canonical Ltd., 2011 Scott James Remnant

This is free software; see the source for copying conditions.  There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
ubuntu@ip-172-31-25-228:~$

Ubuntu 16.04 の起動システム

ubuntu16$ sudo stat /proc/1/exe
  File: '/proc/1/exe' -> '/lib/systemd/systemd'   # <= systemd
  Size: 0               Blocks: 0          IO Block: 1024   symbolic link
Device: 4h/4d   Inode: 9340        Links: 1
Access: (0777/lrwxrwxrwx)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2017-06-28 06:13:46.476000000 +0000
Modify: 2017-06-28 06:13:46.472000000 +0000
Change: 2017-06-28 06:13:46.472000000 +0000
 Birth: -

調べたところ予想どおりUbuntu 14.04ではUpstartが、Ubuntu 16.04ではsystemdが採用されていることがわかります。 どうしてsysvinitでないのにcfn-initは所望の動作をしてくれたのでしょうか?

cfn-initのソースを追っていくとサービスの有効・無効化にはupdate-rc.dを使用している ことがわかりました。 ログからもその動作が垣間見えます。

# /var/log/cfn-init.log@ubuntu14
2017-06-28 06:14:28,533 [DEBUG] Using service modifier: /usr/sbin/update-rc.d
2017-06-28 06:14:28,533 [DEBUG] Setting service cron to disabled
2017-06-28 06:14:28,639 [INFO] disabled service cron
2017-06-28 06:14:28,639 [DEBUG] Using service runner: /usr/sbin/service
2017-06-28 06:14:28,654 [DEBUG] Stopping service cron as it is running
2017-06-28 06:14:28,778 [INFO] Stopped cron successfully
2017-06-28 06:14:28,779 [INFO] ConfigSets completed
# /var/log/cfn-init.log@ubuntu16
2017-06-28 06:04:00,068 [DEBUG] Using service modifier: /usr/sbin/update-rc.d
2017-06-28 06:04:00,069 [DEBUG] Setting service cron to disabled
2017-06-28 06:04:00,080 [INFO] disabled service cron
2017-06-28 06:04:00,081 [DEBUG] Using service runner: /usr/sbin/service
2017-06-28 06:04:00,087 [DEBUG] Stopping service cron as it is running
2017-06-28 06:04:00,098 [INFO] Stopped cron successfully
2017-06-28 06:04:00,099 [INFO] ConfigSets completed

update-rc.dは下記のとおり、System V スタイルの起動設定をサポートしているとのこと。その意味ではcfn-initの説明と合致します。

update-rc.d updates the System V style init script links /etc/rcrunlevel.d/NNname whose target is the script /etc/init.d/name. These links are run by init when it changes runlevels; they are generally used to start and stop system services such as daemons. runlevel is one of the runlevels supported by init, namely, 0123456789S, and NN is the two-digit sequence number that determines where in the sequence init will run the scripts.

http://manpages.ubuntu.com/manpages/precise/man8/update-rc.d.8.html

残る疑問

さきほど調べたとおり今回のテストサーバの起動システムはUpstartおよびsystemdでした。sysvinitとは合致しません。どうしてcronデーモンの停止は実現したのでしょうか?またcronデーモン以外のサービスでも同様の動きになるでしょうか?

まとめ

今回はここまでです。次回は先述の疑問を解明すべくさらにいろいろ試したいと思います。


このエントリーをはてなブックマークに追加

ECSでオートスケーリングを設定/実装するために必要だった事の単なるメモです。
何かの役に立てたら幸いです。

スケールアウト

EC2のスケールアウトと同様に、CloudWatchのアラームをトリガーにしてスケールさせます。
スケール対象がオートスケーリンググループではなく、ScalableTargetを介してECSサービスのDesiredCountを変更するため、CloudFormationのテンプレートはEC2と若干異なります。

{
  "ServiceScalingTarget": {
    "Type": "AWS::ApplicationAutoScaling::ScalableTarget",
    "Properties": {
      "MinCapacity": 2,
      "MaxCapacity": 10,
      "ResourceId": { "Fn::Join": ["", [
        "service/", { "Fn::ImportValue": "SomeECSCluster" }, "/", { "Fn::GetAtt": ["SomeECSService", "Name" ]}
      ] ] },
      "RoleARN": { "Fn::GetAtt": ["SomeAutoScalingRole", "Arn"] },
      "ScalableDimension": "ecs:service:DesiredCount",
      "ServiceNamespace": "ecs"
    },
    "DependsOn": "SomeECSService"
  },
  "ServiceScalingOutPolicy": {
    "Type": "AWS::ApplicationAutoScaling::ScalingPolicy",
    "Properties": {
      "PolicyName": "SomeServerScalingOutPolicy",
      "PolicyType": "StepScaling",
      "ScalingTargetId": { "Ref": "ServiceScalingTarget" },
      "StepScalingPolicyConfiguration": {
        "AdjustmentType": "PercentChangeInCapacity",
        "Cooldown": 60,
        "MetricAggregationType": "Average",
        "StepAdjustments": [
          { "MetricIntervalLowerBound": 0, "ScalingAdjustment": 200 }
        ]
      }
    }
  },
}

EC2の場合と異なりスケールアウトする際のデプロイが必要ないため、DesiredCountを変更するだけの簡単なお仕事です。
EC2の場合はホスト数を変更する以外に以下の実装が必要になります。

  • スケールアウト時にCodeDeployなどを使ってデプロイする処理
  • デプロイ失敗時の処理
  • デプロイ成功後にELBへアタッチする処理

スケールイン

基本的にスケールアウトと同様に、CloudWatchのアラームをトリガーにしてスケールさせます。

ECSサービスがスケールインされる際にコンテナは停止されようとしますが、停止されるまでの最大待ち時間はデフォルト30秒なので注意が必要な場合があります。
もしコンテナを停止させるまで30秒以上かかる可能性がある場合は、ECS Agentに環境変数ECS_CONTAINER_STOP_TIMEOUTを渡し、待ち時間を変更する必要があります。

参考: Amazon ECS Container Agent Configuration - Amazon EC2 Container Service http://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-agent-config.html

コンテナ停止に関係する処理(スケールイン、デプロイなど)が一様に遅くなるため、終了までの時間は短く維持できるように心がけた方がよさそうです。

補足: デプロイ時に古いコンテナは新しいコンテナと交換される形で停止されるため、デプロイ速度にも影響が出ます。(コンテナは更新できない仕様)

コンテナ停止待ちによるコンテナ実行待ち

コンテナ停止待ち時間を長くする事による、デメリットの1つです。

まずECSの仕様として、「停止待ち状態」に移行したコンテナは、他のコンテナをホストにアサインする際のリソース消費量に関与しなくなるという振舞いがあります。
そのため、ホストの空きリソースがあまりない状況下では、デプロイしたコンテナが「実行待ち状態」のまま停止してしまって見える現象が起こりえます。

状況としては以下のようになっています。

  • コンテナをホストにアサインするためのリソースは足りていたため、コンテナがホストにアサインされた。
  • コンテナを実行するためのリソースがホストには不足していたため、「実行待ち状態」で待機している。
# 状態としては以下のようなイメージ
ホスト (2048MiB)
- コンテナ1 (512MiB) # 停止待ち
- コンテナ2 (512MiB) # 実行中
- コンテナ3 (512MiB) # 実行中
- コンテナ4 (512MiB) # 実行中
- コンテナ5 (512MiB) # 実行待ち

この「停止待ち状態」は、最大で「コンテナ停止待ち時間」と同じ時間だけ継続されます。

ホストのスケールアウト

ECSとEC2はお互いに殆ど関与していません。
ECSサービスがスケールアウトしようとしている時に、ECSクラスタ上に十分なリソースがなければスケールアウトできません。

ECSクラスタのリソースが不足しないように、クラスタに参加しているEC2インスタンスもスケールアウトさせる必要があります。

閾値の計算方法

基本的には以下の計算方法でスケールアウトの閾値を決定する事が出来るかと思います。

閾値 = 1 - MAX(クラスタ内のコンテナの必要リソース) / ホストのリソース

  • コンテナ1: メモリ512MiB
  • コンテナ2: メモリ256MiB
  • ホスト: 8GiB
1 - 512MiB / 8GiB = 0.9375 = 93.75%

この例だと、メモリの使用量が93.75%を上回らないようにホストを追加するようにすれば、リソースが足りずにECSサービスがスケールアウトできない状態を殆どなくす事ができます。

EC2インスタンスの起動時間があるため、閾値を85%などに下げ、早めにホストを追加する事によって、コンテナ起動までの待ち時間を最小限にできる。

ホストのスケールイン

スケールアウトの時と同様に、EC2はECSに関与しないので、コンテナが起動していようがEC2インスタンス(ホスト)がシャットダウンしてしまいます。
リクエストを捌いている最中のコンテナが停止してしまったらもちろんレスポンスは返せません。困りましたね。

これを解決するためには、ホストがスケールインによってシャットダウンされる前に、ホスト内の全てのコンテナを停止させ、ホスト内の全てのコンテナの停止を待機するよう実装する必要があります。
AWSではこの事を「コンテナインスタンス(ホスト)のドレイニング」と呼ぶようです。

ホストのドレイニング

AutoScalingのライフサイクルフック, AWS SNS, AWS Lambda を組み合わせた方法がAWSの公式ブログにまとめてあり、作業量は多めですが参考にしてうまくいきました。

参考: Amazon ECS におけるコンテナ インスタンス ドレイニングの自動化方法 | Amazon Web Services ブログ https://aws.amazon.com/jp/blogs/news/how-to-automate-container-instance-draining-in-amazon-ecs/


このエントリーをはてなブックマークに追加

はじめに

最近はプライベートではGoのコードを書いていることが多いのですが、GoではTable Driven Testというやり方が知られています。
このテストコードの書き方が個人的にとても分かりやすく気に入っているので、PHPでテスト書く時も参考にしています。
Table Driven Testを意識してテストコードを書くようにしてから、コードレビュー時に「テストが分かりやすくなった」と言ってもらえたりすることもあったので、ご紹介したいと思います。

Table Driven Testとは

Goの公式Githubリポジトリで紹介されているテスト手法の一つです。
一般的には「Data Driven Test」や「Parameterized Test」とも呼ばれているみたいで、特に真新しいしいやり方ではないようです。
PHPのBDDテストフレームワークであるBehatでも、テーブル形式のテスト方法が用意されていたりもします。

Goでのサンプルコード

GoのTable Driven Testで掲載されているサンプルコードは若干見づらいのですが、他のテストに関するページで分かりやすいコードが紹介されていたので、そちらを紹介します。

package stringutil

import "testing"

func TestReverse(t *testing.T) {
	cases := []struct {
		in, want string
	}{
		{"Hello, world", "dlrow ,olleH"},
		{"Hello, 世界", "界世 ,olleH"},
		{"", ""},
	}
	for _, c := range cases {
		got := Reverse(c.in)
		if got != c.want {
			t.Errorf("Reverse(%q) == %q, want %q", c.in, got, c.want)
		}
	}
}

ちょっとGoのコードに慣れていない人には読みづらいかもしれませんが、「入力した文字列の順番を逆に並び替える」という処理になります。
注目して欲しい点は以下の通りです。

  • テスト対象の関数の入力と出力を構造を定義している
  • 構造の定義の直後に実データも定義している

データ構造の定義と実データのセットがとても近い場所が行われているので、テストデータの内容がとても分かりやすいです。
また、入力と出力というシンプルなデータ構造をテストデータとすることにより、この関数がどういった機能を持っているのかというが一目瞭然だと思います。

PHPでやってみる

上のGoのコードを見て、「これってPHPUnitのDataProviderの機能に似ているな」と感じました。
実際に書いてみようと思います。

プロダクションコード

<?php
/**
 * This file is part of the MyVendor.MyPackage
 *
 * @license http://opensource.org/licenses/MIT MIT
 */
namespace MyVendor\MyPackage;

class MyPackage
{
    public function reserve($string){
        preg_match_all('/./us', $string, $array);

        return join('', array_reverse($array[0]));
    }
}

マルチバイト文字を含む文字列の並び替えというのが、PHPの標準関数に存在しなかったので簡単に実装してみました。
UTF8限定です。

テストコード

<?php

namespace MyVendor\MyPackage;

class MyPackageTest extends \PHPUnit_Framework_TestCase
{
    /**
     * @var MyPackage
     */
    protected $skeleton;

    protected function setUp()
    {
        parent::setUp();
        $this->skeleton = new MyPackage;
    }

    /**
     * @param $in
     * @param $expected
     * @dataProvider providerReserveTestData
     */
    public function testReverse($in, $expected)
    {
        $this->assertEquals($expected, $this->skeleton->reserve($in));
    }

    public function providerReserveTestData()
    {
        return [
            '半角英数字のみ' => [
                'in' => 'Hello, world',
                'expected' => 'dlrow ,olleH'
            ],
            'マルチバイト文字含む' => [
                'in' => 'Hello, 世界',
                'expected' => '界世 ,olleH'
            ],
            '空' => [
                'in' => '',
                'expected' => ''
            ],
        ];
    }
}

PHPではデータ構造の定義とデータの初期化を同時に行うことは出来ないので、こんな感じに連想配列にしてテストデータを渡すようにしてみました。
これでどういった値を入力すると、どんな結果が得られるかが分かりやすくなっていて良い感じじゃないでしょうか。

テストには関係ないのですが、連想配列のキーにどういったテストデータなのかの説明を書くようにもしています。
そうすることで、テスト時にどのデータでエラーになったかが分かりやすくなります。

$ ./vendor/bin/phpunit
PHPUnit 5.7.19 by Sebastian Bergmann and contributors.

...F                                                                4 / 4 (100%)

Time: 202 ms, Memory: 6.00MB

There was 1 failure:

1) MyVendor\MyPackage\MyPackageTest::testReverse with data set "マルチバイト文字含む" ('Hello, 世界', '界世 ,OlleH')
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'界世 ,OlleH'
+'界世 ,olleH'

/path/to/tests/MyPackageTest.php:31

FAILURES!
Tests: 4, Assertions: 4, Failures: 1.

Table Driven Testでテストを書きやすいようにプロダクションコードを設計する

プロダクションコードがTable Driven Testでテストしやすい形になっているということは、以下のことが満たされているコードではないかと思っています。

  1. 入力及び出力が必要最低限に抑えられている
  2. 出力に変化を与える要素が全て入力に含まれている
  3. 出力が何かしら存在する
  4. 依存する対象がインターフェイスになっている

入力及び出力が必要最低限に抑えられている

これは単純に入出力が多いとテストデータを作るのが大変になるからです。
入出力が多い場合は、テスト対象が担う役割が多過ぎる可能性があるのかなと疑い、機能分割出来ないか考えてみることにしています。

出力に変化を与える要素が全て入力に含まれている

入力に明示的に現れていない、暗黙的な入力が出力結果に影響を及ぼしているとテストしづらいことが多いです。

暗黙的な入力の具体例としては、

  • インスタンスのプロパティ
  • 環境変数
  • グローバル変数
  • 現在の時間

といったところでしょうか。

こういった暗黙的な入力が出力結果に影響を与えてしまう場合は、引数に含められないか検討してみます。
また、これらの依存の排除がよりコードを複雑にしてしまったりして必ずしもいい結果を及ぼさないこともあるかと思いますので、出来るだけ検討してみるという感じで進めています。

出力が何かしら存在する

これは単純に出力結果が無いと検証しづらいからですね。

出力結果が無い場合の代表例が

  • ファイル作成
  • 標準出力への出力

といった外部へ出力するケースかと思います。
こういうケースは FileWriterStdoutWriter のようなものを用意して、それらのオブジェクトに出力させるように実装して、テストの時にはモック化するというのが一般的かと思います。

ちなみにGoの場合は io.Writer というインターフェイスが公式で用意されているので、それを利用することが一般的です。

依存する対象がインターフェイスになっている

インターフェイスに依存しているということは、モックを作りやすい=テストしやすいということです。
PHPUnitのモックは具象クラスも上手くモックしてくれることも多いですが、極力インターフェイスに依存するほうが望ましいかなと思います。

具体的には、

  • ライブラリを利用する時にインターフェイスが用意されていない
  • 外部のWeb APIを直接利用

といった場合でしょうか。
このケースも出来るだけインターフェイスに依存できないか検討してみるようにしています。

おわりに

如何でしたでしょうか?

Table Driven Testでテストしづらいコードは、良くないコードである

とまでは一概にはいえないかもしれませんが、設計を見直すべきサインになっている場合も結構あるというのが個人的な実感です。
実際の開発現場では色々な要因があるのでなんでもかんでもTable Driven Testでやるべきだとは思いませんし、テストにおいてもYAGNI精神とのバランスは大事かなとも思います。

Table Driven Testを上手く利用することで、よりよいコードを書く手助けになると思いますので、今後も上手に使っていけたらなと思います。

あと、このように他の言語で学んだことをPHPなどで流用して実践出来たりすると楽しいなと思いました。
今後も他の言語をどんどん触って、いろんなエッセンスを日々のプログラミングに取り入れていけたらなと思います!