テスト駆動開発から始めるドメイン駆動設計入門 ~エンティティ~

初めに

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

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

ユーザーストーリー

前回の 値オブジェクト に続いて今回は エンティティ を作成します。まず ユーザーストーリー から追加作業を TODO リスト に追加します。

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

TODO リスト

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

    • ✓ ユーザーを登録する

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

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

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

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

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

    • ❏ ユーザー名を変更できるようにする

    • ❏ ユーザーの同一性を判断できるようにする

      • ❏ 識別子を追加する

      • ❏ エンティティの比較のを行う

明白な実装

ユーザー名を変更する

追加した TODO リストテストファースト で片づけるため最初にテストコードの追加から始めます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
describe 'ユーザーを更新する' do
def setup
id = UserId.new('1')
name = UserName.new('Bob')
@user = User.new(user_id: id, user_name: name)
end

def test_ユーザー名を更新する
@user.change_name('Alice')
assert_equal 'Alice', @user.name
end
end
end

テストを実行して失敗することを確認します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ ruby test/user_test.rb
Started with run options --seed 16647

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

ユーザーを更新する
test_ユーザー名を更新する ERROR (0.00s)
Minitest::UnexpectedError: NoMethodError: undefined method change_name' for #<User:0x00007fdc9101e850>
test/user_test.rb:61:in `test_ユーザー名を更新する'


Finished in 0.00382s
6 tests, 7 assertions, 0 failures, 1 errors, 0 skips

続いて、メソッドの追加します。簡単な実装なので 明白な実装 で片づけるとします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class User
attr_reader :id, :name

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

def change_name(name)
raise if name.nil?

@name = name
end
end

続いて、テストが通ることを確認します。

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

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

ユーザーを更新する
test_ユーザー名を更新する PASS (0.00s)

Finished in 0.00127s
6 tests, 8 assertions, 0 failures, 0 errors, 0 skips

ユーザーの同一性を判断する

エンティティ として同一性を判断するためのテストケースを追加します。

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
...
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 = UserId.new('2')
name = UserName.new('Bob')
@user2 = User.new(user_id: id, user_name: name)

refute @user.eql?(@user2)
end

def test_同じ名前の同じユーザー
assert @user.eql?(@user)
end

def test_名前を変更した同じユーザー
@user.change_name('Alice')

assert @user.eql?(@user)
end
end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ ruby test/user_test.rb
Started with run options --seed 20456

ユーザーの同一性を判断する
test_同じ名前の同じユーザー PASS (0.00s)
test_同じ名前の異なるユーザー PASS (0.00s)
test_名前を変更した同じユーザー PASS (0.00s)

ユーザーを更新する
test_ユーザー名を更新する PASS (0.00s)

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

Finished in 0.00166s
9 tests, 11 assertions, 0 failures, 0 errors, 0 skips

比較メソッドを識別子で判定するようにオーバーライドします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class User
attr_reader :id, :name

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

def change_name(name)
raise if name.nil?

@name = name
end

def eql?(other)
@id == other.id
end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 $ ruby test/user_test.rb
Started with run options --seed 1326

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

ユーザーの同一性を判断する
test_同じ名前の同じユーザー PASS (0.00s)
test_同じ名前の異なるユーザー PASS (0.00s)
test_名前を変更した同じユーザー PASS (0.00s)

ユーザーを更新する
test_ユーザー名を更新する PASS (0.00s)

Finished in 0.00226s
9 tests, 11 assertions, 0 failures, 0 errors, 0 skips

リファクタリング

メソッドの委譲

eql? メソッドを == に委譲します。

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
class User
attr_reader :id, :name

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

def change_name(name)
raise if name.nil?

@name = name
end

def eql?(other)
self == other
end

def ==(other)
other.equal?(self) || (other.instance_of?(self.class) && other.id == id
end

def hash
id.hash
end
end

変更によりコードが壊れていないことを確認します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ ruby test/user_test.rb
Started with run options --seed 37624

ユーザーの同一性を判断する
test_同じ名前の異なるユーザー PASS (0.00s)
test_名前を変更した同じユーザー PASS (0.00s)
test_同じ名前の同じユーザー PASS (0.00s)

ユーザーを更新する
test_ユーザー名を更新する PASS (0.00s)

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

Finished in 0.00164s
9 tests, 11 assertions, 0 failures, 0 errors, 0 skips

モジュール分割

テストコードの基本部分をヘルパーとして分割して共通利用できるようにしておきます。

test_helper.rb を作成します。

1
2
3
4
5
require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new(color: true)]
require 'minitest/autorun'

user_test.rb の先頭部部を変更します。

1
2
3
4
5
require './test/test_helper'
require './lib/sns.rb'

class UserTest < Minitest::Test
...

リリース

静的コード解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ rubocop
The following cops were added to RuboCop, but are not configured. Please set Enabled to either `true` or `false` in your `.rubocop.yml` file:
- Lint/RaiseException (0.81)
- Lint/StructNewOverride (0.81)
- Style/HashEachMethods (0.80)
- Style/HashTransformKeys (0.80)
- Style/HashTransformValues (0.80)
For more information: https://docs.rubocop.org/en/latest/versioning/
The following cops were added to RuboCop, but are not configured. Please set Enabled to either `true` or `false` in your `.rubocop.yml` file:
- Lint/RaiseException (0.81)
- Lint/StructNewOverride (0.81)
- Style/HashEachMethods (0.80)
- Style/HashTransformKeys (0.80)
- Style/HashTransformValues (0.80)
For more information: https://docs.rubocop.org/en/latest/versioning/
Inspecting 5 files
.....

5 files inspected, no offenses detected

コードカバレッジ

2020042201

2020042202

TODO リスト

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

    • ✓ ユーザーを登録する

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

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

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

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

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

    • ✓ ユーザー名を変更できるようにする

    • ✓ ユーザーの同一性を判断できるようにする

      • ✓ 識別子を追加する

      • ✓ エンティティの比較のを行う

ファイル構成

/main.rb
  |--lib/
      |
       -- sns.rb
       -- user_id.rb
       -- user_name.rb
       -- user.rb
  |--test/
      |
       -- test_helper.rb
       -- 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
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# frozen_string_literal: true

# User
class User
attr_reader :id, :name

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

def change_name(name)
raise if name.nil?

@name = name
end

def eql?(other)
@id == other.id
end

def ==(other)
other.equal?(self) || other.instance_of?(self.class) && other.id == id
end

def hash
id.hash
end
end

/test/test_helper.rb.

1
2
3
4
5
6
7
# frozen_string_literal: true

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

/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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# frozen_string_literal: true

require './test/test_helper'
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

describe 'ユーザーを更新する' do
def setup
id = UserId.new('1')
name = UserName.new('Bob')
@user = User.new(user_id: id, user_name: name)
end

def test_ユーザー名を更新する
@user.change_name('Alice')
assert_equal 'Alice', @user.name
end
end

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 = UserId.new('2')
name = UserName.new('Bob')
@user2 = User.new(user_id: id, user_name: name)

refute @user.eql?(@user2)
end

def test_同じ名前の同じユーザー
assert @user.eql?(@user)
end

def test_名前を変更した同じユーザー
@user.change_name('Alice')

assert @user.eql?(@user)
end
end
end

ふりかえり

まず、ユーザーストーリー から追加の TODO リスト を作成しました。
テストファースト で最初に失敗するテストから始めて 明白な実装 によりユーザ名を更新するメソッドを追加しました。

続いて、値オブジェクト であるユーザーオブジェクトを エンティティ
として扱えるようにするためユーザーの同一性を判断するためのメソッドを追加しました。
そして、メソッドの委譲のリファクタリングを実施後、テストを実行してコードが壊れていないことを確認しました。

仕上げに、ヘルパーファイルを抽出してテストファイルで共有できるようにしました。

今回のテーマである エンティティ に関しては、書籍『リファクタリング』第8章 データの再編成 値から参照への変更で言及されています。

多くのシステムにおいて、参照オブジェクトと値オブジェクトを分けて考えることが役立ちます。「参照オブジェクト」とは、顧客とか勘定といったもので、実世界における1個のオブジェクトを表しており、それらが同じものかどうかを調べるには、オブジェクト識別が用いられます。「値オブジェクト」とは、日付やお金のようなもので、もっぱら、それ自体のデータ値によって定義されます。それらのコピーはいくつあってもかまいません。

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

値オブジェクトエンティティ に関してはリファクタリングカタログで 値から参照への変更参照から値への変更 として解説されています。

値から参照への変更

同じインスタンスが多数存在するクラスがある。それらを1つのオブジェクトに置き換えたい。

そのオブジェクトを参照オブジェクトに変える。

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

参照から値への変更

小さくて、不変で、コントロールが煩わしい参照オブジェクトがある。

値オブジェクトに変える。

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

次回は ドメインサービス の実装に取り組んでみたいと思います。

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