はじめに
この記事では、お題に対して筆者がテスト駆動開発を実践した過程をまとめています。
ゼロから動作するきれいなコードが完成するまでの「ひとつの例」として参考になれば幸いです。
言語はJavaScript、テストフレームワークはJestを使用します。
扱わないこと。
- テスト駆動開発についての解説
- 文書化されたテスト
- テスト技法
※ テストコードはテンポ重視で書いています。
お題:ローマ数字カタ
”数値を受け取り、それに応じた文字列表現に変換するString convert(int)
メソッドを書きなさい。”
1 ➔ I
2 ➔ II
3 ➔ III
4 ➔ IV
5 ➔ V
9 ➔ IX
21 ➔ XXI
50 ➔ L
100 ➔ C
500 ➔ D
1000 ➔ M
ローマ数字は「1 から 3999 の値が表現できる」ということなので、引数int
は0 < n < 4000を想定します。
実践
最初のテスト
それでは実践していきます。
まずはテストを書きます。どんなテストを書くべきか・・・1の変換?
いえ、まずは環境が正常に動作することを確認しましょう。
describe('ローマ数字のカタ', () => {
test('何もしないテスト', () => {
});
});
このような何もしないテストを書いて、テストを実行します。
✅ 何もしないテスト
作業を始めるための環境は整っているようです。
次はconvert
が実行できるか確認しましょう。
describe('ローマ数字のカタ', () => {
test('convertが実行できる', () => {
convert();
});
});
❌ convertが実行できる
予想通りテストは失敗します。convert
は影も形もないので当然です。
ではプロダクトコードを書いていきましょう。
export function convert() {}
+ import { convert } from './RomanNumerals';
describe('ローマ数字のカタ', () => {
test('convertが実行できる', () => {
convert();
});
});
改めてテストを実行します。
✅ convertが実行できる
レッドからグリーンになりました。
1~3を変換するテスト
いよいよ変換処理に踏み込んでいきます。
もっとも単純なテストはなんだろうか?最初に考えた1の変換にしましょう。
describe('ローマ数字のカタ', () => {
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
expect(convert(1)).toBe('I');
});
});
テストを実行します。
❌ convertは数値をローマ数字に対応した文字列表現に変換する
Expected: "I"
Received: undefined
予想通りではありますが、ちゃんとテストを実行してレッドになることを確認しましょう。グリーンにするのはその次です。
それではプロダクトコードを変更してグリーンにしましょう。
export function convert(int) {
return 'I';
}
非常に単純な実装ではありますが、これでテストを実行します。
✅ convertは数値をローマ数字に対応した文字列表現に変換する
グリーンになりました!
さて次は2を変換してみましょう。
describe('ローマ数字のカタ', () => {
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
expect(convert(1)).toBe('I');
+ expect(convert(2)).toBe('II');
});
});
❌ convertは数値をローマ数字に対応した文字列表現に変換する
Expected: "II"
Received: "I"
新たに追加テストはレッドになります。
さあグリーンにしましょう。
export function convert(int) {
- return 'I';
+ return int === 2 ? 'II' : 'I';
}
1・2に続いて3を変換してみましょう。
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
expect(convert(1)).toBe('I');
expect(convert(2)).toBe('II');
+ expect(convert(3)).toBe('III');
});
❌ convertは数値をローマ数字に対応した文字列表現に変換する
Expected: "III"
Received: "II
新しく追加したテストがレッドになりました。
先程と同じく、まずは単純にグリーンを目指します。
export function convert(int) {
+ if (int === 3) return 'III';
return int === 2 ? 'II' : 'I';
}
✅ convertは数値をローマ数字に対応した文字列表現に変換する
無事グリーンになりました。
さて、ここでプロダクトコードを見直してみましょう。
export function convert(int) {
if (int === 3) return 'III';
return int === 2 ? 'II' : 'I';
}
テストはグリーンになりましたが、このコードは「Iを繰り返して表現する」というルールを表していないように見えます。
export function convert(int) {
if (int === 3) return 'III';
return int === 2 ? 'II' : 'I';
}
このようにしてはどうでしょうか?
export function convert(int) {
let result = '';
if (int >= 3) result += 'I';
if (int >= 2) result += 'I';
if (int >= 1) result += 'I';
return result;
}
テストはグリーンのままです。
このような形にするとループ処理が見えてきそうですが、この場合はもっと単純な方法があります。repeat
メソッドを使いましょう。
export function convert(int) {
return 'I'.repeat(int);
}
テストはグリーンのままに、リファクタリングをしました。
4を変換するテスト
3の次は4を変換してみましょう。
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
expect(convert(1)).toBe('I');
expect(convert(2)).toBe('II');
expect(convert(3)).toBe('III');
+ expect(convert(4)).toBe('IV');
});
❌ convertは数値をローマ数字に対応した文字列表現に変換する
Expected: "IV"
Received: "IIII"
レッドになりました。4はこれまでとは異なり、減算する形で表記します。
単純な実装でグリーンにしましょう。
export function convert(int) {
if (int <= 3) return 'I'.repeat(int);
if (int === 4) return 'IV';
return '';
}
✅ convertは数値をローマ数字に対応した文字列表現に変換する
5を変換する
次に進みましょう。5を変換します。
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
// ...
expect(convert(4)).toBe('IV');
+ expect(convert(5)).toBe('V');
});
テストはレッドになりますが、簡単にグリーンにできます。
export function convert(int) {
if (int <= 3) return 'I'.repeat(int);
if (int === 4) return 'IV';
+ if (int === 5) return 'V';
return '';
}
✅ convertは数値をローマ数字に対応した文字列表現に変換する
6~8を変換する
まずは6から始めましょう。
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
// ...
expect(convert(5)).toBe('V');
+ expect(convert(6)).toBe('VI');
});
テストがレッドになることを確認し、グリーンにしていきます。
export function convert(int) {
if (int <= 3) return 'I'.repeat(int);
if (int === 4) return 'IV';
if (int === 5) return 'V';
+ if (int === 6) return 'VI';
return '';
}
✅ convertは数値をローマ数字に対応した文字列表現に変換する
6はOKです。次は7に取り組みましょう。
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
// ...
expect(convert(5)).toBe('V');
expect(convert(6)).toBe('VI');
+ expect(convert(7)).toBe('VII');
});
6~8は1~3と同じように I を繰り返します。少し歩幅を大きくして一気にやってしまいましょう。
export function convert(int) {
if (int <= 3) return 'I'.repeat(int);
if (int === 4) return 'IV';
if (int === 5) return 'V';
- if (int === 6) return 'VI';
+ if (int <= 8) return 'V' + 'I'.repeat(int - 5);
return '';
}
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
// ...
expect(convert(5)).toBe('V');
expect(convert(6)).toBe('VI');
expect(convert(7)).toBe('VII');
+ expect(convert(8)).toBe('VIII');
});
8の変換もグリーンになります。
しかし、いきなりグリーンになるテストを追加してしまいました。このテストコードは問題があったときに正しくレッドになるのでしょうか?確認してみましょう。
export function convert(int) {
// ...
- if (int <= 8) // ...
+ if (int <= 7) // ...
return '';
}
このように、プロダクトコードに欠陥を挿入してみます。
❌ convertは数値をローマ数字に対応した文字列表現に変換する
Expected: "VIII"
Received: ""
予想通りテストがレッドになったので、テストコードにも自信が持てそうです。
確認ができたのでプロダクトコードは元に戻しておきます。
9を変換するテスト
9は10(X)から1(I)を減算する形で表現します。
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
// ...
+ expect(convert(9)).toBe('IX');
});
まずは失敗するテストを書きます。次にテストが成功するように実装しましょう。
export function convert(int) {
// ...
if (int <= 8) return 'V' + 'I'.repeat(int - 5);
+ if (int === 9) return 'IX';
return '';
}
これで9の変換もできました。
ここからは少しスピードアップしていきます。細かいところは省略していきますが、逐一テストを実行しレッド→グリーンの流れを踏んでいます。
10~19を変換する
さて、1の位の変換が終わったので次に進みましょう。
まずは10を変換します。
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
// ...
+ expect(convert(10)).toBe('X');
});
export function convert(int) {
// ...
if (int === 9) return 'IX';
+ if (int === 10) return 'X';
return '';
}
これまでと同じように実装してグリーンにします。
さて、次は11ですがここでいったん見直してみましょう。
10の変換を最後に追加しましたが、11を変換するにはそれぞれの位ごとに変換する必要があります。
まずは、10の変換を移動します。
export function convert(int) {
+ if (int === 10) return 'X';
if (int <= 3) return 'I'.repeat(int);
if (int === 4) return 'IV';
if (int === 5) return 'V';
if (int <= 8) return 'V' + 'I'.repeat(int - 5);
if (int === 9) return 'IX';
- if (int === 10) return 'X';
return '';
}
次に、入力された整数からそれぞれの位の値を抽出しましょう。
export function convert(int) {
const tens = Math.floor(int / 10) % 10;
if (tens === 1) return 'X';
const once = int % 10;
if (once <= 3) return 'I'.repeat(once);
if (once === 4) return 'IV';
if (once === 5) return 'V';
if (once <= 8) return 'V' + 'I'.repeat(once - 5);
if (once === 9) return 'IX';
return '';
}
1の位の変換処理は、関数convertOnce
に抽出しましょう。
function convertOnce(once) {
if (once <= 3) return 'I'.repeat(once);
if (once === 4) return 'IV';
if (once === 5) return 'V';
if (once <= 8) return 'V' + 'I'.repeat(once - 5);
if (once === 9) return 'IX';
return '';
}
export function convert(int) {
const tens = Math.floor(int / 10) % 10;
if (tens === 1) return 'X';
const once = int % 10;
return convertOnce(once);
}
特定の位の値を取得する処理も関数として抽出しましょう。
function extractDigitValue(int, digit) {
return Math.floor(int / digit) % 10;
}
export function convert(int) {
const tens = extractDigitValue(int, 10);
if (tens === 1) return 'X';
const once = extractDigitValue(int, 1);
return convertOnce(once);
}
テストはグリーンのままリファクタリングを進めていきました。
改めて11の変換に取り掛かりましょう。
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
// ...
+ expect(convert(11)).toBe('XI');
});
それぞれの位を変換した結果を、文字列結合すればよさそうです。
export function convert(int) {
let result = '';
const tens = extractDigitValue(int, 10);
if (tens === 1) result += 'X';
const once = extractDigitValue(int, 1);
result += convertOnce(once);
return result;
}
グリーンになりました。
念の為、他の数も試してみましょう。例えば 19 はどうでしょうか?
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
// ...
+ expect(convert(19)).toBe('XIX');
});
これもグリーンになりました。
10の位を変換する
取り掛かる前に、変換処理を関数convertTens
に抽出しましょう。
function convertTens(tens) {
if (tens === 1) return 'X';
return '';
}
export function convert(int) {
let result = '';
const tens = extractDigitValue(int, 10);
result += convertTens(tens);
const once = extractDigitValue(int, 1);
result += convertOnce(once);
return result;
}
1の位の変換と同じように、convertTens
関数を変更していきます。
ここは同じような流れになるので詳細は割愛します。テストは次のようになりました。
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
// ...
+ expect(convert(20)).toBe('XX');
+ expect(convert(40)).toBe('XL');
+ expect(convert(50)).toBe('L');
+ expect(convert(80)).toBe('LXXX');
+ expect(convert(90)).toBe('XC');
});
続いてプロダクトコードです。
function convertTens(tens) {
if (tens <= 3) return 'X'.repeat(tens);
if (tens === 4) return 'XL';
if (tens === 5) return 'L';
if (tens <= 8) return 'L' + 'X'.repeat(tens - 5);
if (tens === 9) return 'XC';
return '';
}
さて、テストはグリーンになったのでリファクタリングをします。
2つの変換処理を比較してみましょう。
function convertOnce(once) {
if (once <= 3) return 'I'.repeat(once);
if (once === 4) return 'IV';
if (once === 5) return 'V';
if (once <= 8) return 'V' + 'I'.repeat(once - 5);
if (once === 9) return 'IX';
return '';
}
function convertTens(tens) {
if (tens <= 3) return 'X'.repeat(tens);
if (tens === 4) return 'XL';
if (tens === 5) return 'L';
if (tens <= 8) return 'L' + 'X'.repeat(tens - 5);
if (tens === 9) return 'XC';
return '';
}
比較してみると2つの関数はよく似ています。一般化をしていきましょう。
まず4と9は2つの文字を結合するので、そのように表現します。
function convertOnce(once) {
if (once <= 3) return 'I'.repeat(once);
- if (once === 4) return 'IV';
+ if (once === 4) return 'I' + 'V';
if (once === 5) return 'V';
if (once <= 8) return 'V' + 'I'.repeat(once - 5);
- if (once === 9) return 'IX';
+ if (once === 9) return 'I' + 'X';
return '';
}
function convertTens(tens) {
if (tens <= 3) return 'X'.repeat(tens);
- if (tens === 4) return 'XL';
+ if (once === 4) return 'X' + 'L';
if (tens === 5) return 'L';
if (tens <= 8) return 'L' + 'X'.repeat(tens - 5);
- if (tens === 9) return 'XC';
+ if (tens === 9) return 'X' + 'C';
return '';
}
このようにしてみると、3つの文字の組み合わせであることがわかります。
それぞれの文字を変数として抽出してみましょう。
(わかりやすいように数値を表す変数も統一します)
function convertOnce(once) {
const lower = 'I';
const middle = 'V';
const upper = 'X';
const int = once;
if (int <= 3) return lower.repeat(int);
if (int === 4) return lower + middle;
if (int === 5) return middle;
if (int <= 8) return middle + lower.repeat(int - 5);
if (int === 9) return lower + upper;
return '';
}
function convertTens(tens) {
const lower = 'X';
const middle = 'L';
const upper = 'C';
const int = tens;
if (int <= 3) return lower.repeat(int);
if (int === 4) return lower + middle;
if (int === 5) return middle;
if (int <= 8) return middle + lower.repeat(int - 5);
if (int === 9) return lower + upper;
return '';
}
これで変換のルールはまったく同じ状態になったので、関数に抽出しましょう。
function baseConvertRule(lower, middle, upper) {
return function (int) {
if (int <= 3) return lower.repeat(int);
if (int === 4) return lower + middle;
if (int === 5) return middle;
if (int <= 8) return middle + lower.repeat(int - 5);
if (int === 9) return lower + upper;
return '';
}
}
const convertOnce = baseConvertRule('I', 'V', 'X');
const convertTens = baseConvertRule('X', 'L', 'C');
抽出した関数baseConvertRule
は、変換する3つの文字を引数に取り、実際に変換する関数を戻り値として返します。
これで変換処理の一般化が完了し、テストはグリーンの状態です。
現時点のプロダクトコードを確認し、次に進みましょう。
現時点のプロダクトコード
function baseConvertRule(lower, middle, upper) {
return function (int) {
if (int <= 3) return lower.repeat(int);
if (int === 4) return lower + middle;
if (int === 5) return middle;
if (int <= 8) return middle + lower.repeat(int - 5);
if (int === 9) return lower + upper;
return '';
}
}
const convertOnce = baseConvertRule('I', 'V', 'X');
const convertTens = baseConvertRule('X', 'L', 'C');
function extractDigitValue(int, digit) {
return Math.floor(int / digit) % 10;
}
export function convert(int) {
let result = '';
const tens = extractDigitValue(int, 10);
result += convertTens(tens);
const once = extractDigitValue(int, 1);
result += convertOnce(once);
return result;
}
100の位の変換
変換処理を一般化したので、100の位に進みましょう。
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
// ...
+ expect(convert(100)).toBe('C');
});
100の位も変換のルールは1の位や10の位と同じです。
+ const convertHundreds = baseConvertRule('C', 'D', 'M');
export function convert(int) {
let result = '';
+ const handreds = extractDigitValue(int, 100);
+ result += convertHundreds(handreds);
const tens = extractDigitValue(int, 10);
result += convertTens(tens);
// ...
}
これでテストはグリーンになりました。
追加でいくつかテストしてみましょう。
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
// ...
expect(convert(100)).toBe('C');
+ expect(convert(101)).toBe('CI');
+ expect(convert(110)).toBe('CX');
+ expect(convert(200)).toBe('CC');
+ expect(convert(400)).toBe('CD');
+ expect(convert(500)).toBe('D');
+ expect(convert(900)).toBe('CM');
});
テストは引き続きグリーンです。これで100の位の変換は完了しました。
1000の位の変換
最後の変換に取り掛かります。
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
// ...
+ expect(convert(1000)).toBe('M');
});
1000の位はこれまでとは異なります。1000の位を示す文字はMしかなく、3000までしか表現できません。
よって、例外的に変換処理を定義します。
+ const convertThousands = (thousands) => {
+ if (thousands <= 3) return 'M'.repeat(thousands);
+ return '';
+ };
export function convert(int) {
let result = '';
+ const handreds = extractDigitValue(int, 100);
+ result += convertHundreds(handreds);
// ...
}
テストはグリーンになりました。
さらに追加でいくつかテストしてみましょう。
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
// ...
+ expect(convert(1999)).toBe('MCMXCIX');
+ expect(convert(3999)).toBe('MMMCMXCIX');
});
✅ convertは数値をローマ数字に対応した文字列表現に変換する
テストはグリーンのままです。
これで全て完了・・・ではなくリファクタリングですね。
リファクタリング
最後にリファクタリングを行います。convert
関数を見てみましょう。
export function convert(int) {
let result = '';
const thousands = extractDigitValue(int, 1000);
result += convertThousands(thousands);
const handreds = extractDigitValue(int, 100);
result += convertHundreds(handreds);
const tens = extractDigitValue(int, 10);
result += convertTens(tens);
const once = extractDigitValue(int, 1);
result += convertOnce(once);
return result;
}
convert
関数を見てみると、一定のパターンを繰り返していることがわかります。
- 入力された整数から特定の位の値を抽出する
- 抽出した値をローマ数字に変換する
- 変換したローマ数字を文字列結合する
異なるのは桁と変換ルールですから、次のようなルールセットにまとめることができます。
export function convert(int) {
const rules = [
{ digit: 1000, converter: convertThousands },
{ digit: 100, converter: convertHundreds },
{ digit: 10, converter: convertTens },
{ digit: 1, converter: convertOnce },
];
// ...
}
ルールセットの処理はfor
文やforEach
でもできそうですが、「処理結果を引き継いでいく」という性質からreduce
が使えそうです。
export function convert(int) {
const rules = [
{ digit: 1000, converter: convertThousands },
{ digit: 100, converter: convertHundreds },
{ digit: 10, converter: convertTens },
{ digit: 1, converter: convertOnce },
];
return rules.reduce((result, { digit, converter }) => {
return result + converter(extractDigitValue(int, digit));
}, '');
}
これで本当に完了です。
完成したコード全体
import { convert } from './RomanNumerals2';
describe('ローマ数字のカタ', () => {
test('convertは数値をローマ数字に対応した文字列表現に変換する', () => {
expect(convert(1)).toBe('I');
expect(convert(2)).toBe('II');
expect(convert(3)).toBe('III');
expect(convert(4)).toBe('IV');
expect(convert(5)).toBe('V');
expect(convert(6)).toBe('VI');
expect(convert(7)).toBe('VII');
expect(convert(8)).toBe('VIII');
expect(convert(9)).toBe('IX');
expect(convert(10)).toBe('X');
expect(convert(11)).toBe('XI');
expect(convert(19)).toBe('XIX')
expect(convert(20)).toBe('XX');
expect(convert(40)).toBe('XL');
expect(convert(50)).toBe('L');
expect(convert(80)).toBe('LXXX');
expect(convert(90)).toBe('XC');
expect(convert(100)).toBe('C');
expect(convert(101)).toBe('CI');
expect(convert(110)).toBe('CX');
expect(convert(200)).toBe('CC');
expect(convert(400)).toBe('CD');
expect(convert(500)).toBe('D');
expect(convert(900)).toBe('CM');
expect(convert(1000)).toBe('M');
expect(convert(1999)).toBe('MCMXCIX');
expect(convert(3999)).toBe('MMMCMXCIX');
});
});
function baseConvertRule(lower, middle, upper) {
return function (int) {
if (int <= 3) return lower.repeat(int);
if (int === 4) return lower + middle;
if (int === 5) return middle;
if (int <= 8) return middle + lower.repeat(int - 5);
if (int === 9) return lower + upper;
return '';
}
}
const convertOnce = baseConvertRule('I', 'V', 'X');
const convertTens = baseConvertRule('X', 'L', 'C');
const convertHundreds = baseConvertRule('C', 'D', 'M');
const convertThousands = (thousands) => {
if (thousands <= 3) return 'M'.repeat(thousands);
return '';
};
function extractDigitValue(int, digit) {
return Math.floor(int / digit) % 10;
}
export function convert(int) {
const rules = [
{ digit: 1000, converter: convertThousands },
{ digit: 100, converter: convertHundreds },
{ digit: 10, converter: convertTens },
{ digit: 1, converter: convertOnce },
];
return rules.reduce((result, { digit, converter }) => {
return result + converter(extractDigitValue(int, digit));
}, '');
}
まとめ
テスト駆動開発の実践として、「数値をローマ数字を表す文字列表現に変換」というお題に取り組んでみました。まだまだ試行錯誤している途中ですが、テストを活用し、動作するきれいなコードを導いていく過程が示せたのではないかと思います。
しかし、これはあくまで一例です。登山に例えると、頂上までのルートはさまざまで、これは私が辿ったひとつのルートに過ぎません。人によってはきっと違ったルートが見つかることでしょう。
まだテスト駆動開発を経験したことがない方は、ぜひ一度挑戦してみることをお勧めします。テスト駆動開発は一つのスタイルですが、今までとは違った視点でのプログラミングが体験できると思います。
この記事が、自分なりの歩き方見つけるための参考になれば幸いです。