テスト駆動開発から始めるドメイン駆動設計入門 ~値オブジェクト~

初めに

この記事は書籍『ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本』で解説されているドメイン駆動設計パターンをテスト駆動開発で実装したものです。

言語は Ruby です。Ruby でのテスト駆動開発の詳細に関してはこちらの記事をご参照ください。Gitpodからブラウザ開発環境が利用できるのでお手軽に開発を始めることが出来ます。

ユーザーストーリー

まず ユーザーストーリー をもとに仕様を整理します。

ユーザーストーリーとは、ソフトウェアシステムに求められるふるまいをまとめたものだ。アジャイルソフトウェア開発の世界で広く使われており、大量の機能を細かく分解して計画作りに生かせるようにしている。
同じような概念を表す用語としてフィーチャーという言い方もあるが、
最近のアジャイル界隈では「ストーリー」とか「ユーザーストーリー」とかいう用語のほうが広まっている。

— Martin Fowler’s Bliki (ja) https://bliki-ja.github.io/UserStory

SNS(ソーシャルネットワークサービス)のユーザー機能ということなので以下の ユーザーストーリー を作成しました。

利用者として
ユーザーを管理できるようにしたい
なぜならユーザーはシステムを利用するために必要だから

TODO リスト

ユーザーストーリー を作成したらそれをもとに TODO リスト を作成します。
TODO リスト はプログラムとして実行できる粒度で具体的に記述します。

  • ❏ ユーザーを管理できるようにする

    • ❏ ユーザーを登録する

      • ❏ ID と名前を持ったユーザーを作成する

仮実装

ユーザーを登録する

さっそく TODO リスト の1つ目を片付けましょう。
まずは テストファースト で最初に失敗するコードを書きます。

1
2
3
4
5
6
7
8
9
class HelloTest < Minitest::Test
def test_greeting
assert_equal 'hello world', greeting
end
end

def greeting
'hello world'
end

サンプルコードを以下のコードに書き換えてテストを実行します。

1
2
3
4
5
6
7
class UserTest < Minitest::Test
def test_IDと名前を持ったユーザーを作成する
user = User.new
assert_equal '1', user.id
assert_equal 'Bob', user.name
end
end

テストは失敗しました。 NameError: uninitialized constant UserTest::User クラスが定義されていないからですね。

1
2
3
4
5
6
7
8
9
10
11
$ ruby test/hello_test.rb
Started with run options --seed 44125

UserTest
test_IDと名前を持ったユーザーを作成する ERROR (0.00s)
Minitest::UnexpectedError: NameError: uninitialized constant UserTest::User
test/hello_test.rb:7:in test_IDと名前を持ったユーザーを作成する


Finished in 0.00135s
1 tests, 0 assertions, 0 failures, 1 errors, 0 skips

テストをパスさせるために User クラスを追加します。
まずはテストをパスさせるために 仮実装 でベタ書きのコードを実装します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class UserTest < Minitest::Test
def test_IDと名前を持ったユーザーを作成する
user = User.new
assert_equal '1', user.id
assert_equal 'Bob', user.name
end
end

class User
attr_accessor :id, :name

def initialize
@id = '1'
@name = 'Bob'
end
end

テストをパスさせてレッドからグリーンになりました。

1
2
3
4
5
6
7
8
$ ruby test/hello_test.rb
Started with run options --seed 55832

UserTest
test_IDと名前を持ったユーザーを作成する PASS (0.00s)

Finished in 0.00072s
1 tests, 2 assertions, 0 failures, 0 errors, 0 skips

仮実装から実装へ

ユーザーを登録する

テストは通りましたがコードはベタ書きのままです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class UserTest < Minitest::Test
def test_IDと名前を持ったユーザーを作成する
user = User.new
assert_equal '1', user.id
assert_equal 'Bob', user.name
end
end

class User
attr_accessor :id, :name

def initialize
@id = '1'
@name = 'Bob'
end
end

仮実装 のままでは別のユーザーを作ることが出来ないので、コンストラクタ経由で作成できるようにします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class UserTest < Minitest::Test
def test_IDと名前を持ったユーザーを作成する
user = User.new('1', 'Bob')
assert_equal '1', user.id
assert_equal 'Bob', user.name
end
end

class User
attr_accessor :id, :name

def initialize(id, name)
@id = id
@name = name
end
end

テストが通りました。

1
2
3
4
5
6
7
8
$ ruby test/hello_test.rb
Started with run options --seed 6402

UserTest
test_IDと名前を持ったユーザーを作成する PASS (0.00s)

Finished in 0.00089s
1 tests, 2 assertions, 0 failures, 0 errors, 0 skips

仮実装から実装へ を経て一つ目の TODO リスト を片付けたのでここでバージョン管理システムを使ってコミットしておきます。

1
2
$ git add .
$ git commit -m 'test: ユーザーを登録する'

以下、 TODO リスト を片付けるたびにコミットしていきます。

リファクタリング

  • ❏ ユーザーを管理できるようにする

    • ❏ ユーザーを登録する

      • ✓ ID と名前を持ったユーザーを作成する

レッド・グリーンときたので続いて リファクタリング を実施します。

メソッドの抽出

本来はコードの重複が発生してからやるのですが今回は予め メソッドの抽出 を適用してフィクスチャーを抽出しておきます。

1
2
3
4
5
6
7
class UserTest < Minitest::Test
def test_IDと名前を持ったユーザーを作成する
user = User.new('1', 'Bob')
assert_equal '1', user.id
assert_equal 'Bob', user.name
end
end

Ruby のテスティングフレームワーク minitest ではフィクスチャーは setup メソッドです。

1
2
3
4
5
6
7
8
9
10
class UserTest < Minitest::Test
def setup
@user = User.new('1', 'Bob')
end

def test_IDと名前を持ったユーザーを作成する
assert_equal '1', @user.id
assert_equal 'Bob', @user.name
end
end

テストが壊れていないことを確認したらコミットします。

明白な実装

続いて TODO リスト を追加します。

  • ❏ ユーザーを管理できるようにする

    • ❏ ユーザーを登録する

      • ✓ ID と名前を持ったユーザーを作成する

      • ユーザー名が3文字未満の場合はエラー

ユーザーを登録する

追加した TODO リスト に取り掛かります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class UserTest < Minitest::Test
def setup
@user = User.new('1', 'Bob')
end

def test_IDと名前を持ったユーザーを作成する
assert_equal '1', @user.id
assert_equal 'Bob', @user.name
end
end

class User
attr_accessor :id, :name

def initialize(id, name)
@id = id
@name = name
end
end

まず、失敗するテストを書いて 明白な実装 でテストをパスするようにします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class UserTest < Minitest::Test
def setup
@user = User.new('1', 'Bob')
end

def test_IDと名前を持ったユーザーを作成する
assert_equal '1', @user.id
assert_equal 'Bob', @user.name
end

def test_ユーザー名が3文字未満の場合はエラー
e = assert_raises RuntimeError do
User.new('1', 'a')
end

assert_equal 'ユーザー名は3文字以上です。', e.message
end
end

class User
attr_accessor :id, :name

def initialize(id, name)
raise 'ユーザー名は3文字以上です。' if name.length < 3

@id = id
@name = name
end
end

レッドからグリーンになったことを確認したらコミットします。

リファクタリング

  • ❏ ユーザーを管理できるようにする

    • ❏ ユーザーを登録する

      • ✓ ID と名前を持ったユーザーを作成する

      • ✓ ユーザー名が3文字未満の場合はエラー

レッド・グリーン・リファクタリングです。

クラスの抽出

クラスの抽出 を適用して User クラスから 値オブジェクト を抽出する リファクタリング を適用します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class UserTest < Minitest::Test
def setup
@user = User.new('1', 'Bob')
end

def test_IDと名前を持ったユーザーを作成する
assert_equal '1', @user.id
assert_equal 'Bob', @user.name
end

def test_ユーザー名が3文字未満で新規登録する場合はエラー
e = assert_raises RuntimeError do
User.new(1, 'a')
end

assert_equal 'ユーザー名は3文字以上です。', e.message
end
end

class User
attr_accessor :id, :name

def initialize(id, name)
raise 'ユーザー名は3文字以上です。' if name.length < 3

@id = id
@name = name
end
end

まずは UserId クラスを抽出します。テストコードを UserId クラスを使って呼び出すように変更したらエラーを修正してグリーンの状態を維持します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class UserTest < Minitest::Test
def setup
id = UserId.new('1')
@user = User.new(id, 'Bob')
end

def test_IDと名前を持ったユーザーを作成する
assert_equal '1', @user.id.value
assert_equal 'Bob', @user.name
end
...
end

class UserId
attr_accessor :value

def initialize(value)
@value = value
end
end

...

続いて UserName クラスを抽出します。テストコードも同様に変更します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class UserTest < Minitest::Test
def setup
id = UserId.new('1')
name = UserName.new('Bob')
@user = User.new(id, name)
end

def test_IDと名前を持ったユーザーを作成する
assert_equal '1', @user.id.value
assert_equal 'Bob', @user.name.value
end
...
end

...

class UserName
attr_accessor :value

def initialize(value)
raise 'ユーザー名は3文字以上です。' if value.length < 3

@value = value
end
end

...

テストコードを修正してグリーンになったらコミットして クラスの抽出リファクタリング 完了です。

1
2
3
4
5
6
7
8
9
$ruby test/hello_test.rb
Started with run options --seed 59746

UserTest
test_ユーザー名が3文字未満の場合はエラー PASS (0.00s)
test_IDと名前を持ったユーザーを作成する PASS (0.00s)

Finished in 0.00071s
2 tests, 4 assertions, 0 failures, 0 errors, 0 skips

set メソッドの削除

クラスの抽出 により 値オブジェクト を抽出することは出来ましたがインスタンスの値が変更可能な状態です。
set メソッドの削除 を適用して 値オブジェクト の要求を満たす不変オブジェクトに リファクタリング しましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class UserTest < Minitest::Test
def setup
id = UserId.new('1')
name = UserName.new('Bob')
@user = User.new(id, name)
end

def test_IDと名前を持ったユーザーを作成する
assert_equal '1', @user.id.value
assert_equal 'Bob', @user.name.value
end

def test_ユーザー名が3文字未満の場合はエラー
e = assert_raises RuntimeError do
UserName.new('a')
end

assert_equal 'ユーザー名は3文字以上です。', e.message
end
end

class UserId
attr_accessor :value

def initialize(value)
@value = value
end
end

class UserName
attr_accessor :value

def initialize(value)
raise 'ユーザー名は3文字以上です。' if value.length < 3

@value = value
end
end

class User
attr_accessor :id, :name

def initialize(id, name)
@id = id
@name = name
end
end

アックセッサメソッドを読み取り専用に変更します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...

class UserId
attr_reader :value

...
end

class UserName
attr_reader :value

...
end

class User
attr_reader :id, :name

...
end

テストが壊れていないことを確認したらコミットします。

1
2
3
4
5
6
7
8
9
$ ruby test/hello_test.rb
Started with run options --seed 62273

UserTest
test_ユーザー名が3文字未満の場合はエラー PASS (0.00s)
test_IDと名前を持ったユーザーを作成する PASS (0.00s)

Finished in 0.00075s
2 tests, 4 assertions, 0 failures, 0 errors, 0 skips

例外ケース

正常系の実装が出来たので続いて例外系の実装に入りたいと思います。
まず TODO リスト を追加します。

  • ❏ ユーザーを管理できるようにする

    • ❏ ユーザーを登録する

      • ✓ ID と名前を持ったユーザーを作成する

      • ✓ ユーザー名が3文字未満の場合はエラー

      • ユーザー名が4文字の場合は登録される

      • ユーザー名を指定しない場合はエラー

      • ID を指定しない場合はエラー

ユーザーを登録する

追加した TODO リスト をテストを壊さないように1つづつ片付けていくとしましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class UserTest < Minitest::Test
def setup
id = UserId.new('1')
name = UserName.new('Bob')
@user = User.new(id, name)
end

def test_IDと名前を持ったユーザーを作成する
assert_equal '1', @user.id.value
assert_equal 'Bob', @user.name.value
end

def test_ユーザー名が3文字未満の場合はエラー
e = assert_raises RuntimeError do
UserName.new('a')
end

assert_equal 'ユーザー名は3文字以上です。', e.message
end
end

class UserId
attr_reader :value

def initialize(value)
@value = value
end
end

class UserName
attr_reader :value

def initialize(value)
raise 'ユーザー名は3文字以上です。' if value.length < 3

@value = value
end
end

class User
attr_reader :id, :name

def initialize(id, name)
@id = id
@name = name
end
end

実装後のコードです。
実際は1つテストコードを追加したらプロダクトコードを実装してレッド・グリーンのサイクルを回しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
class UserTest < Minitest::Test
def setup
id = UserId.new('1')
name = UserName.new('Bob')
@user = User.new(id, name)
end

def test_IDと名前を持ったユーザーを作成する
assert_equal '1', @user.id.value
assert_equal 'Bob', @user.name.value
end

def test_ユーザー名が3文字未満の場合はエラー
e = assert_raises RuntimeError do
UserName.new('a')
end

assert_equal 'ユーザー名は3文字以上です。', e.message
end

def test_ユーザー名が4文字の場合は登録される
user = User.new(UserId.new('1'), UserName.new('abcd'))
assert_equal 'abcd', user.name.value
end

def test_ユーザー名を指定しない場合はエラー
assert_raises RuntimeError do
UserName.new(nil)
end
end

def test_IDを指定しない場合はエラー
assert_raises RuntimeError do
UserId.new(nil)
end
end
end

class UserId
attr_reader :value

def initialize(value)
raise if value.nil?

@value = value
end
end

class UserName
attr_reader :value

def initialize(value)
raise if value.nil?
raise 'ユーザー名は3文字以上です。' if value.length < 3

@value = value
end
end

class User
attr_reader :id, :name

def initialize(id, name)
@id = id
@name = name
end
end

例外系の機能を追加してテストもパスしたのでコミットします。

リファクタリング

  • ❏ ユーザーを管理できるようにする

    • ✓ ユーザーを登録する

      • ✓ ID と名前を持ったユーザーを作成する

      • ✓ ユーザー名が3文字未満の場合はエラー

      • ✓ ユーザー名を指定しない場合はエラー

      • ✓ ユーザー名が4文字の場合は登録される

      • ✓ ID を指定しない場合はエラー

今回はコードの可読性を改善する観点で リファクタリング を実施してみたいと思います。

メソッドのインライン化

テストコードが増えてきましたここでテストコードをグルーピングするため メソッドのインライン化 を適用します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
class UserTest < Minitest::Test
def setup
id = UserId.new('1')
name = UserName.new('Bob')
@user = User.new(id, name)
end

def test_IDと名前を持ったユーザーを作成する
assert_equal '1', @user.id.value
assert_equal 'Bob', @user.name.value
end

def test_ユーザー名が3文字未満の場合はエラー
e = assert_raises RuntimeError do
UserName.new('a')
end

assert_equal 'ユーザー名は3文字以上です。', e.message
end

def test_ユーザー名が4文字の場合は登録される
user = User.new(UserId.new('1'), UserName.new('abcd'))
assert_equal 'abcd', user.name.value
end

def test_ユーザー名を指定しない場合はエラー
assert_raises RuntimeError do
UserName.new(nil)
end
end

def test_IDを指定しない場合はエラー
assert_raises RuntimeError do
UserId.new(nil)
end
end
end

class UserId
attr_reader :value

def initialize(value)
raise if value.nil?

@value = value
end
end

class UserName
attr_reader :value

def initialize(value)
raise if value.nil?
raise 'ユーザー名は3文字以上です。' if value.length < 3

@value = value
end
end

class User
attr_reader :id, :name

def initialize(id, name)
@id = id
@name = name
end
end

テストコードの構造を TODO リスト の構造に合わせることで可読性を改善します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class UserTest < Minitest::Test
describe 'ユーザーを登録する' do
def setup
id = UserId.new('1')
name = UserName.new('Bob')
@user = User.new(id, name)
end

def test_IDと名前を持ったユーザーを作成する
assert_equal '1', @user.id.value
assert_equal 'Bob', @user.name.value
end

def test_ユーザー名が3文字未満の場合はエラー
e = assert_raises RuntimeError do
UserName.new('a')
end

assert_equal 'ユーザー名は3文字以上です。', e.message
end

def test_ユーザー名が4文字の場合は登録される
user = User.new(UserId.new('1'), UserName.new('abcd'))
assert_equal 'abcd', user.name.value
end

def test_ユーザー名を指定しない場合はエラー
assert_raises RuntimeError do
UserName.new(nil)
end
end

def test_IDを指定しない場合はエラー
assert_raises RuntimeError do
UserId.new(nil)
end
end
end
end

...

実行結果もわかりやすいなりました。 テストは壊れていないのでコミットします。

1
2
3
4
5
6
7
8
9
10
11
12
$ ruby test/hello_test.rb
Started with run options --seed 39340

ユーザーを登録する
test_IDと名前を持ったユーザーを作成する PASS (0.00s)
test_IDを指定しない場合はエラー PASS (0.00s)
test_ユーザー名が3文字未満の場合はエラー PASS (0.00s)
test_ユーザー名を指定しない場合はエラー PASS (0.00s)
test_ユーザー名が4文字の場合は登録される PASS (0.00s)

Finished in 0.00217s
5 tests, 7 assertions, 0 failures, 0 errors, 0 skips

キーワード引数の導入

テストコードは読みやすくなりました。続いてプロダクトコードを改善しましょう。
動的言語である Ruby では型を明示しないため引数の値がリテラルなのか 値オブジェクト なのかメソッドの定義だけでは把握できません。キーワード引数の導入 をしてできるだけ引数の型を把握しやすいように リファクタリング しましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
class UserTest < Minitest::Test
describe 'ユーザーを登録する' do
def setup
id = UserId.new('1')
name = UserName.new('Bob')
@user = User.new(id, name)
end

def test_IDと名前を持ったユーザーを作成する
assert_equal '1', @user.id.value
assert_equal 'Bob', @user.name.value
end

def test_ユーザー名が3文字未満の場合はエラー
e = assert_raises RuntimeError do
UserName.new('a')
end

assert_equal 'ユーザー名は3文字以上です。', e.message
end

def test_ユーザー名が4文字の場合は登録される
user = User.new(UserId.new('1'), UserName.new('abcd'))
assert_equal 'abcd', user.name.value
end

def test_ユーザー名を指定しない場合はエラー
assert_raises RuntimeError do
UserName.new(nil)
end
end

def test_IDを指定しない場合はエラー
assert_raises RuntimeError do
UserId.new(nil)
end
end
end
end

class UserId
attr_reader :value

def initialize(value)
raise if value.nil?

@value = value
end
end

class UserName
attr_reader :value

def initialize(value)
raise if value.nil?
raise 'ユーザー名は3文字以上です。' if value.length < 3

@value = value
end
end

class User
attr_reader :id, :name

def initialize(id, name)
@id = id
@name = name
end
end

キーワード引数値オブジェクト と同じ名称にします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class UserTest < Minitest::Test
describe 'ユーザーを登録する' do
def setup
id = UserId.new('1')
name = UserName.new('Bob')
@user = User.new(user_id: id, user_name: name)
end
...
end

...

class User
attr_reader :id, :name

def initialize(user_id:, user_name:)
@id = user_id
@name = user_name
end
end

テストを修正してグリーンになったらコミットします。
ちなみに キーワード引数の導入 という名称はリファクタリングのカタログにはない用語です。Ruby 固有のパターンとして便宜上命名しています。

モジュール分割

TODO リスト を全部片付けたのでここで単一ファイルから各クラスモジュールごとに モジュール分割 を実施します。

TODO リスト

  • ❏ ユーザーを管理できるようにする

    • ✓ ユーザーを登録する

      • ✓ ID と名前を持ったユーザーを作成する

      • ✓ ユーザー名が3文字未満の場合はエラー

      • ✓ ユーザー名を指定しない場合はエラー

      • ✓ ユーザー名が4文字の場合は登録される

      • ✓ ID を指定しない場合はエラー

クラス図

diag-3a0995bbcfccf7896acca758266aea29.png

ファイル構成

/main.rb
  |--lib/
      |
       -- sns.rb
       -- user_id.rb
       -- user_name.rb
       -- user.rb
  |--test/
      |
       -- user_test.rb

/main.rb.

1
require './test/user_test.rb'

/lib/sns.rb.

1
2
3
4
5
# frozen_string_literal: true

require './lib/user_id.rb'
require './lib/user_name.rb'
require './lib/user.rb'

/lib/user_id.rb.

1
2
3
4
5
6
7
8
9
10
11
12
# frozen_string_literal: true

# User ID value object
class UserId
attr_reader :value

def initialize(value)
raise if value.nil?

@value = value
end
end

/lib/user_name.rb.

1
2
3
4
5
6
7
8
9
10
11
12
13
# frozen_string_literal: true

# User name value object
class UserName
attr_reader :value

def initialize(value)
raise if value.nil?
raise 'ユーザー名は3文字以上です。' if value.length < 3

@value = value
end
end

/lib/user.rb.

1
2
3
4
5
6
7
8
9
10
11
# frozen_string_literal: true

# User
class User
attr_reader :id, :name

def initialize(user_id:, user_name:)
@id = user_id
@name = user_name
end
end

/test/user_test.rb.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# frozen_string_literal: true

require 'minitest/reporters'
Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new(color: true)]
require 'minitest/autorun'
require './lib/sns.rb'

class UserTest < Minitest::Test
describe 'ユーザーを登録する' do
def setup
id = UserId.new('1')
name = UserName.new('Bob')
@user = User.new(user_id: id, user_name: name)
end

def test_IDと名前を持ったユーザーを作成する
assert_equal '1', @user.id.value
assert_equal 'Bob', @user.name.value
end

def test_ユーザー名が3文字未満の場合はエラー
e = assert_raises RuntimeError do
UserName.new('a')
end

assert_equal 'ユーザー名は3文字以上です。', e.message
end

def test_ユーザー名が4文字の場合は登録される
user = User.new(user_id: UserId.new('1'),
user_name: UserName.new('abcd'))
assert_equal 'abcd', user.name.value
end

def test_ユーザー名を指定しない場合はエラー
assert_raises RuntimeError do
UserName.new(nil)
end
end

def test_IDを指定しない場合はエラー
assert_raises RuntimeError do
UserId.new(nil)
end
end
end
end

リリース

モジュール分割 により最初のリリースの準備が出来ました。
リリース前に 静的コード解析コードカバレッジ を実施してコードの品質を確認しておきましょう。
手順の詳細は こちらの記事 をご参照ください。

静的コード解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
$ rubocop
Inspecting 5 files
....C

Offenses:

test/user_test.rb:11:3: C: Metrics/BlockLength: Block has too many lines. [30/25]
describe 'ユーザーを登録する' do ...
^^^^^^^^^^^^^^^^^^^^^^^
test/user_test.rb:18:9: C: Naming/MethodName: Use snake_case for method names.
def test_IDと名前を持ったユーザーを作成する
^^^^^^^^^^^^^^^^^^^^^^^
test/user_test.rb:18:16: C: Naming/AsciiIdentifiers: Use only ascii symbols in identifiers.
def test_IDと名前を持ったユーザーを作成する
^^^^^^^^^^^^^^^^
test/user_test.rb:23:9: C: Naming/MethodName: Use snake_case for method names.
def test_ユーザー名が3文字未満の場合はエラー
^^^^^^^^^^^^^^^^^^^^^^^
test/user_test.rb:23:14: C: Naming/AsciiIdentifiers: Use only ascii symbols in identifiers.
def test_ユーザー名が3文字未満の場合はエラー
^^^^^^^^^^^^^^^^^^
test/user_test.rb:31:9: C: Naming/MethodName: Use snake_case for method names.
def test_ユーザー名が4文字の場合は登録される
^^^^^^^^^^^^^^^^^^^^^^^
test/user_test.rb:31:14: C: Naming/AsciiIdentifiers: Use only ascii symbols in identifiers.
def test_ユーザー名が4文字の場合は登録される
^^^^^^^^^^^^^^^^^^
test/user_test.rb:37:9: C: Naming/MethodName: Use snake_case for method names.
def test_ユーザー名を指定しない場合はエラー
^^^^^^^^^^^^^^^^^^^^^^
test/user_test.rb:37:14: C: Naming/AsciiIdentifiers: Use only ascii symbols in identifiers.
def test_ユーザー名を指定しない場合はエラー
^^^^^^^^^^^^^^^^^
test/user_test.rb:43:9: C: Naming/MethodName: Use snake_case for method names.
def test_IDを指定しない場合はエラー
^^^^^^^^^^^^^^^^^^^
test/user_test.rb:43:16: C: Naming/AsciiIdentifiers: Use only ascii symbols in identifiers.
def test_IDを指定しない場合はエラー
^^^^^^^^^^^^

5 files inspected, 11 offenses detected

いくつか警告が表示されていますがテストコードの日本語に関する内容なのでチェック対象から除外することにします。

.rubocop.yml ファイルを以下に更新します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
inherit_from: .rubocop_todo.yml

AllCops:
Include:
- "lib/**/*.rb"
- "test/**/*_test.rb"
Exclude:
- "docs"

Style/AsciiComments:
Enabled: false

Naming/MethodName:
Exclude:
- "test/**"

Naming/AsciiIdentifiers:
Exclude:
- "test/**"

Metrics/BlockLength:
Exclude:
- "test/**"

警告は無くなりました。

1
2
3
4
5
$ rubocop
Inspecting 5 files
.....

5 files inspected, no offenses detected

コードカバレッジ

まず、テストコードからコードカバレッジを実行できるようにします。

user_test.rb の先頭を以下に更新します。

1
2
3
4
5
6
7
8
9
10
# frozen_string_literal: true

require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new(color: true)]
require 'minitest/autorun'
require './lib/sns.rb'

...

テストを実行します。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ruby test/user_test.rb
Started with run options --seed 24571

ユーザーを登録する
test_IDを指定しない場合はエラー PASS (0.00s)
test_ユーザー名が4文字の場合は登録される PASS (0.00s)
test_ユーザー名を指定しない場合はエラー PASS (0.00s)
test_ユーザー名が3文字未満の場合はエラー PASS (0.00s)
test_IDと名前を持ったユーザーを作成する PASS (0.00s)

Finished in 0.00106s
5 tests, 7 assertions, 0 failures, 0 errors, 0 skips
Coverage report generated for Unit Tests to /Users/k2works/Projects/sandbox/tdd_itddd/coverage. 19 / 19 LOC (100.0%) covered.

テストカバレッジは 100%です。

20200222.png

ふりかえり

最初のリリースが完了したのでここでやってきたことのふりかえりをしておきましょう。

まず、ユーザーストーリー から TODO リスト を作成しました。
TODO リスト の1つめを 仮実装 でまずベタ書きのコードを書いてテストをパスするようにしました。
テストをパスしてグリーンになったら 仮実装から実装へ を経て最初の TODO リスト を完了させました。

次の TODO リスト を追加する前にテストコードに メソッドの抽出 を適用して リファクタリング を実施しました。リファクタリング を実施してテストが壊れていないことを確認してから TODO リスト を追加して次の作業に入りました。次の作業ではまず TODO リスト を追加してその内容を 明白な実装 で片付けました。

明白な実装 により再びテストがレッドからグリーンになったので クラスの抽出set メソッドの削除 を適用してリファクタリング を実施することにより 値オブジェクト を追加しました。
リファクタリング を実施してテストが壊れていないことを確認したら 次は例外ケースの TODO リスト を追加しました。

追加した例外ケースを 明白な実装 で片付けたら、まずテストコードに メソッドのインライン化 を適用して プロダクトコードに キーワード引数の導入 を適用してコードの可読性を改善する リファクタリング を実施しました。

仕上げに モジュール分割 を実施しました。
続いて 静的コードコードカバレッジ を実施してコードの品質を確認して、最初のリリースを完了しました。

今回のテーマである 値オブジェクト は書籍『テスト駆動開発』では 第1部 他国通貨 の中で Money クラスとして実装されていますし Value Object パターン として紹介されています。

Value Object パターン

広く共有されるものの、同一インスタンスであることはさほど重要でないオブジェクトを設計するにはどうしたらよいだろうか—–オブジェクト作成時に状態を設定したら、その後決して変えないようにする。オブジェクトへの操作は必ず新しいオブジェクトを返すようにしよう。

— テスト駆動開発

また、書籍『リファクタリング』では 第3章 コードの不吉な臭い の中の 基本データ型への執着 で言及されています。

基本データ型への執着

オブジェクト指向を始めたばかりの人は、小さなオブジェクトを使ってちょっとしたことをさせるのを嫌がる傾向があります。金額と通貨単位を組み合わせた Money(貨幣)クラス、上限と下限と持つ Range(範囲)クラス、電話番号や郵便番号を表すための特殊な文字列クラスなどがこの例に該当します。

— 新装版 リファクタリング

アプリケーション開発の過程でどのように 値オブジェクト を適用するかはこちらの記事をご参照ください。

今回のリリースでユーザーは登録することは出来ましたがユーザー名を変更することが出来ません。

次回は エンティティ の実装に取り組んでみたいと思います。

参考サイト

参考図書

  • ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本 (日本語) 単行本(ソフトカバー) 成瀬 允宣 (著)
    翔泳社 (2020/2/13)

  • テスト駆動開発 Kent Beck (著), 和田 卓人 (翻訳): オーム社; 新訳版 (2017/10/14)

  • 新装版 リファクタリング―既存のコードを安全に改善する― (OBJECT TECHNOLOGY SERIES) Martin
    Fowler (著), 児玉 公信 (翻訳), 友野 晶夫 (翻訳), 平澤 章 (翻訳), その他: オーム社; 新装版
    (2014/7/26)

  • リファクタリング(第 2 版): 既存のコードを安全に改善する (OBJECT TECHNOLOGY SERIES) Martin
    Fowler (著), 児玉 公信 (翻訳), 友野 晶夫 (翻訳), 平澤 章 (翻訳), その他: オーム社; 第 2 版
    (2019/12/1)

Author: k2works
Link: https://k2works.github.io/2020/04/21/1587434415/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.