Edited at

シェルスクリプト(ユニケージ)をPython2.6.6でTDDする

この記事はハンズラボ Advent Calendar 2018 5日目の記事です。


はじめに

こちらは、シェルスクリプト(with ユニケージ)でシステム開発している人が、少しでも開発しやすいようにテストコードを書いてみたという記事です。

とりあえず試してみたよ、という感じなので、内容は浅めです。


この記事の想定読者

この記事は以下の人向けに書いています。


  • 業務システムでシェルスクリプトを書いているが、テストを書いてない。

  • ↑テストを書きたいけど、諸事情につきテストフレームワークがインストールできない。

(こんな稀有な人いない気がするな…)


ユニケージとは?

ざっくり言うと、基本的なシェルスクリプトだけで、バッチ・Webアプリケーションを組み上げる開発手法のことです。

それをもっと簡単に、効率よく実現するために、USP研究所というところがusp Tukubaiというコマンド群を提供しています。

ユニケージ開発手法と調べれば、いくつか記事が出てきますが、よくまとまっているのが以下の記事です。

ハンズラボが採用しているユニケージという謎テクノロジーについて 第1回


シェルスクリプトでTDDをしたい

現在のプロジェクトはいわゆる基幹システムですが、ほぼシェルスクリプトで組まれています。

これまでシェルスクリプトでガッツリ開発をしたことが無くて、テストコードを書くといった意識が正直言えばありませんでした(現在のプロジェクトでもテストコードは皆無)。

ただ、最近テストコードを書かないことで痛い目を見た(見ている)ので、やはりテストを書かなきゃ! という気持ちになり、何かテストフレームワークが無いか探してみました。

シェルスクリプトでのユニットテストフレームワークを探すといくつかあり、以下が代表的なものっぽいです。

(余談ですが、弊社の別チームではbatsを使っています)

結果から言うと、これらのフレームワークは使いませんでした。

理由としては、


  • Unixの基本コマンドだけで構成したシステムを作ろうというユニケージの思想から考えると、テストだけの為に外部のフレームワークを入れることは、移植性を下げそうだと思ったこと。

  • お客様のオンプレ環境で動いてるシステムだったので、フレームワークを勝手に入れられないこと。

  • フレームワーク入れさせて欲しいと申請するのが面倒くさかった。

  • とりあえず一人で小さく試してみたかったって気持ちが強かったので、すでに入っているもので何とかしたかった。


なのでPythonのunittestにしてみる

Unix系OSにはPython2系がプリインストールされていることが多いです。

うちのプロジェクトでの環境も同様でした。

$ python --version

Python 2.6.6

Python2系が入っていれば、unittestが使えます。


シェルスクリプトの出力結果を、Pythonのunittestでテストする


テストコードを書く

ユニケージでは1スクリプトファイルにつき、1つの役割を担います。

ユニケージエンジニアの作法その六 「1プログラム1役」を徹底せよ

各スクリプトは、テキストデータをインプットとして、フィルタ処理したテキストファイルをアウトプットするのが基本形です。

そのため、テスト対象であるスクリプトが吐き出したテキストデータに対して、テストケースを書く方法が良さそうです。

以下に店舗別の仕入データがあったとします。

# $1:伝票区分 $2:店舗コード $3:仕入商品 $4:仕入先コード $5:仕入日

$ cat sire_data
20 0001 みかん 120005 20181001
20 0002 りんご 559995 20181111
21 0003 ぶどう 009995 20180809
30 0004 りんご 000005 20181205

これらのデータから、今年の11月と12月分のの仕入データを抽出するスクリプトを書くとします。

まずは先にテストを書いていきます。


テストコード


# coding:utf-8

import unittest
import commands
import os
import subprocess

class TestSire(unittest.TestCase):

### テスト準備 ###
def setUp(self):
print("テスト準備")
# テスト対象スクリプトを実行
# (終了コード, 標準出力)
result = commands.getstatusoutput("./SIRE ")

self.assertEqual(0, result[0])

if __name__ == "__main__":
unittest.main()



テスト結果

$ python TEST_SIRE.py 

テスト準備
E
======================================================================
ERROR: test_file_exist (__main__.TestSire)
----------------------------------------------------------------------
Traceback (most recent call last):
File "TEST_SIRE.py", line 17, in setUp
self.assertEqual("0", result[0])
AssertionError: '0' != 32512

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (errors=1)


命名とかがアレなのは気にしないで下さい。

今回、テスト対象スクリプトであるSIREバッチを走らせてみましたが、そもそもまだコードを書いていない為、失敗しています。

Unix系コマンドの標準出力を得るだけなら、commands.getoutputだけで充分ですが、

commands.getstatusoutputを使用することによって、終了コードを含めた結果が配列で返ってくるようになります。

([0]:終了コード。[1]:標準出力結果)

その終了コードを見てスクリプト自体が正常終了したかが確認できます。

無事テスト失敗しましたので、速やかにグリーンにしましょう。


SIRE


# ディレクトリ/変数の定義
tmp=/tmp/$(basename $0).$$
dir=./

# 11月以降の仕入データの取得
cat sire_data |
awk '$5>=20181101' > ${dir}/month_sire

exit 0



TEST_SIRE.py

# coding:utf-8


import unittest
import commands
import os
import subprocess

class TestSire(unittest.TestCase):

### テスト準備 ###
def setUp(self):
print("テスト準備")
# テスト対象スクリプトを実行
# (終了コード, 標準出力)
result = commands.getstatusoutput("./SIRE ")

self.assertEqual(0, result[0])

### テストケース ###
def test_file_exist(self):
print("fileが存在するか")
isExist = os.path.exists("month_sire")
self.assertTrue(isExist)

if __name__ == "__main__":
unittest.main()



テスト結果

$ python TEST_SIRE.py 

テスト準備
fileが存在するか
.
----------------------------------------------------------------------
Ran 1 test in 0.008s

OK


少し一足飛びで、os.path.existsでアウトプットしたファイル自体が存在するかどうかのテストケースを追加してみました。


データの中身の確認

次に、出力したファイルがちゃんと11月と12月のみになっているか、テストをしたいと思います。


TEST_SIRE.py

# coding:utf-8


import unittest
import commands
import os
import subprocess

class TestSire(unittest.TestCase):

### テスト準備 ###
def setUp(self):
print("テスト準備")
# テスト対象スクリプトを実行
# (終了コード, 標準出力)
result = commands.getstatusoutput("./SIRE ")

self.assertEqual(0, result[0])

### テストケース ###
def test_file_exist(self):
print("fileが存在するか")
isExist = os.path.exists("month_sire")
self.assertTrue(isExist)

def test_date_201811_and_201812(self):
print("出力したファイルの日付が、2018年11月〜12月のみか")
result = commands.getoutput("awk '{print substr($5,0,6)}' month_sire | LANG=C sort -k1,1")
date_array = result.split()

self.assertEqual('201811', date_array[0])
self.assertEqual('201812', date_array[1])

if __name__ == "__main__":
unittest.main()



テスト結果

$ python TEST_SIRE.py 

テスト準備
出力したファイルの日付が、2018年11月〜12月のみか
.テスト準備
fileが存在するか
.
----------------------------------------------------------------------
Ran 2 tests in 0.014s

OK


アウトプットしたファイル(month_sire)の中身をyyyymmの形で取り出して、昇順でsortし、それをスペース区切りの文字列で受け取りました。

そのままだとテストが難しいため、split()を使い、yyyymmの配列にしてからself.assertEqual()を使用しています。

※Python2.7以降であれば、配列に対して、特定の要素が含まれているかをチェックするassertIn()があります。


assetInでの例

 self.assertIn('201811', date_array)

self.assertIn('201812', date_array)


テスト結果の、「テスト準備」という文字が二回出力されているのを見て、もしかしたらあれ? と思った方もいらっしゃると思いますが、このテストコードは2回SIREスクリプトが走っています。

setUp()が、各テストケースが動く前に実行される関数の為、いまの状態だとテストケースが増える度に、テスト対象のプロダクトコードが動いてしまいます。

アウトプットする結果は同じ(はず)なので、明らかに二度手間となります。

なのでテスト対象のスクリプトは一度きりの実行にしたいです。

ここは古典的にフラグにしてみます。


TEST_SIRE.py

# coding:utf-8


import unittest
import commands
import os
import subprocess

class TestSire(unittest.TestCase):
# フラグ用意
isExecutedProductCode = False

### テスト準備 ###
def setUp(self):

if TestSire.isExecutedProductCode == False :
print("テスト準備")
# テスト対象スクリプトを実行
# (終了コード, 標準出力)
result = commands.getstatusoutput("./SIRE ")

# 実行したらフラグを立てる
TestSire.isExecutedProductCode = True
self.assertEqual(0, result[0])

### テストケース ###
def test_file_exist(self):
print("fileが存在するか")
isExist = os.path.exists("month_sire")
self.assertTrue(isExist)

def test_date_201811_and_201812(self):
print("出力したファイルの日付が、2018年11月〜12月のみか")
result = commands.getoutput("awk '{print substr($5,0,6)}' month_sire | LANG=C sort -k2,1")
date_array = result.split()

self.assertEqual('201811', date_array[0])
self.assertEqual('201812', date_array[1])

if __name__ == "__main__":
unittest.main()



テスト結果

$ python TEST_SIRE.py 

テスト準備
出力したファイルの日付が、2018年11月〜12月のみか
.fileが存在するか
.
----------------------------------------------------------------------
Ran 2 tests in 0.008s

OK


「テスト準備」という文字の出力が一度だけになりました。

これで無駄にプロダクトコードを走らせなくて済みました。

※Python2.7以降であれば、各テストケースを実行する前に一度だけ呼び出される、setUpClass()があります。


setUpClass()の例


@classmethod
def setUpClass(cls):
print("テスト準備")
# テスト対象スクリプトを実行
# (終了コード, 標準出力)
result = commands.getstatusoutput("./SIRE ")

self.assertEqual(0, result[0])



テスト用ディレクトリに差し替える

ところで、ユニケージはフィルタ処理したファイルのデータレベルによって、どこのフォルダに置くかが決まっています。

データレベルについての参考:ハンズラボが採用しているユニケージという謎テクノロジーについて 第3回

そのため、テストをするたびにアウトプット先のファイルが更新されていきます。

他のスクリプトがそのファイルを使用する可能性がある為、テストする度に更新するのは避けたいです。

なので、テストをするときだけ、アウトプット先のフォルダを変更します。

os.environで環境変数を定義し、テスト時にはアウトプット先のフォルダを変更します。


TEST_SIRE.py


# coding:utf-8

import unittest
import commands
import os
import subprocess

class TestSire(unittest.TestCase):
# フラグ用意
isExecutedProductCode = False

### テスト準備 ###
def setUp(self):

if TestSire.isExecutedProductCode == False :
print("テスト準備")

# TEST用のディレクトリを定義する
os.environ['test_dir'] = '/home/user/TEST_DIR'

# テスト対象スクリプトを実行
# (終了コード, 標準出力)
result = commands.getstatusoutput("./SIRE ")

# 実行したらフラグを立てる
TestSire.isExecutedProductCode = True
self.assertEqual(0, result[0])

### テストケース ###
def test_file_exist(self):
print("fileが存在するか")
isExist = os.path.exists(os.environ['test_dir'] + "/month_sire")
self.assertTrue(isExist)

def test_date_201811_and_201812(self):
print("出力したファイルの日付が、2018年11月〜12月のみか")
result = commands.getoutput("awk '{print substr($5,0,6)}' ${test_dir}/month_sire | LANG=C sort -k2,1")
date_array = result.split()

self.assertEqual('201811', date_array[0])
self.assertEqual('201812', date_array[1])

if __name__ == "__main__":
unittest.main()


プロダクトコードの方も、若干変更を加えます。


# ディレクトリ/変数の定義
tmp=/tmp/$(basename $0).$$
dir=./

# ユニットテスト依存性注入
[ -n "${test_dir}" ] && dir=${test_dir}

# 11月以降の仕入データの取得
cat sire_data |
awk '$5>=20181101' > ${dir}/month_sire

これにより、テスト時と、実際に実行された時のアウトプット先が変更されます。

今回はアウトプット先を変更しましたが、インプット用のファイルパスも同じようなやり方で変更することができます。

日次バッチだと毎回インプットデータが更新されていたりして、テストするたびにデータが変わってしまう事がありえます。

なので、テストデータを別フォルダに用意しておき、テストの際にはそのフォルダにあるファイルを参照するといったやり方に切り替えれば、毎回同じテスト結果を得ることができます。


まとめ

Pythonでテストを書いたと言っていますが、内部的に見ると、commands.getoutput()で、ただシェルコマンド実行し、その結果をassertしているだけの代物です。

それでもシェルスクリプト単体よりは、多少表現力が増したのではないかと思います。

(シェルスクリプトのtestコマンドは扱いづらい……)