MoleculeでAnsibleのRoleをテストしてみた


背景

Ansibleは便利な構成管理ツールであり、Roleを開発することでサーバ構築、ネットワーク接続、クラウドサービスの設定などを複数まとめて行ってくれます。
しかし、AnsibleのRoleはRoleそのものの修正や、設定先のOSのバージョンアップやAnsible自体のバージョンアップで修正する必要が出てくる時があります。修正の度にRoleが問題ないかテストする必要が出てきますが、何回もテストをするという煩雑さがありました。
そこで今回は、Moleculeの基礎を触ってAnsible Roleのテスト自動化をできるための準備を進めてみたいと思います。
※参考文献 SoftwareDesign 2020/6月号
(https://gihyo.jp/magazine/SD/archive/2020/202006)

Moleculeとは

MoleculeはAnsible Roleのテストを支援してくれるためのツールです。
https://molecule.readthedocs.io/en/latest/
MoluculeはAnsibleの最新安定バージョンと一個前のバージョンのみサポートされています。
(2020/10現在のAnsibleの最新安定バージョンは2.9系なため、2.8系までサポート対象です)
他にも要求スペックとしては、Python3.6系以上で、2系が対象外であることが挙げられます。
OS毎にインストール手順が異なっていますが、今回はCentOS 8にインストールしてみました。

インストール方法

基本的には公式リファレンスを基にインストール作業を進めています。pipパッケージでインストールしますので、pipに必要なパッケージを事前にインストールします。
※CentOS8の場合
sudo yum install -y gcc python3-pip python3-devel openssl-devel python3-libselinux
pipパッケージのインストールが完了しましたら、pipでmoluculeをインストールします。
python3 -m pip install --user "molecule[lint]"
次に今回テストに使用するpodmanドライバをインストールします。
python3 -m pip install --user 'molecule[podman]'

初期設定

Moleculeのインストールが完了しましたらテンプレートを用意します。

$ molecule init role testmole
--> Initializing new role testmole...
Initialized role in /work/testmole successfully.
$ tree testmole/
testmole/
├── README.md
├── defaults
│   └── main.yml
├── files
├── handlers
│   └── main.yml
├── meta
│   └── main.yml
├── molecule
│   └── default
│       ├── INSTALL.rst
│       ├── converge.yml
│       ├── molecule.yml
│       └── verify.yml
├── tasks
│   └── main.yml
├── templates
├── tests
│   ├── inventory
│   └── test.yml
└── vars
    └── main.yml

10 directories, 12 files

molecule init role ロール名でmoleculeを実行できるテンプレートディレクトリ構成が構築されます。(ここではtestmoleというディレクトリを作成)
ansible-galaxy initと似たようなディレクトリ構成ですが、変更点としてmoleculeディレクトリが作成されていることが大きな特徴となっております。
この段階でテストの試し打ちを行うこともできます。

$ cd testmole && molecule test
--> Test matrix

└── default
    ├── dependency
    ├── lint
    ├── cleanup
    ├── destroy
    ├── syntax
    ├── create
    ├── prepare
    ├── converge
    ├── idempotence
    ├── side_effect
    ├── verify
    ├── cleanup
    └── destroy

--> Scenario: 'default'
--> Action: 'dependency'
Skipping, missing the requirements file.
Skipping, missing the requirements file.
--> Scenario: 'default'
--> Action: 'lint'
--> Lint is disabled.
--> Scenario: 'default'
--> Action: 'cleanup'
Skipping, cleanup playbook not configured.
--> Scenario: 'default'
--> Action: 'destroy'
--> Sanity checks: 'podman'

    PLAY [Destroy] *****************************************************************

    TASK [Destroy molecule instance(s)] ********************************************
    changed: [localhost] => (item=None)
    changed: [localhost]

    TASK [Wait for instance(s) deletion to complete] *******************************
    changed: [localhost] => (item=None)
    changed: [localhost]

    PLAY RECAP *********************************************************************
    localhost                  : ok=2    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

--> Scenario: 'default'
--> Action: 'syntax'

    playbook: /home/yuhta/Desktop/Molecule/testmole/molecule/default/converge.yml
--> Scenario: 'default'
--> Action: 'create'

    PLAY [Create] ******************************************************************

    TASK [Log into a container registry] *******************************************
    skipping: [localhost] => (item=None) 

    TASK [Check presence of custom Dockerfiles] ************************************
    ok: [localhost] => (item=None)
    ok: [localhost]

    TASK [Create Dockerfiles from image names] *************************************
    skipping: [localhost] => (item=None) 

    TASK [Discover local Podman images] ********************************************
    ok: [localhost] => (item=None)
    ok: [localhost]

    TASK [Build an Ansible compatible image] ***************************************
    skipping: [localhost] => (item={'changed': False, 'skipped': True, 'skip_reason': 'Conditional result was False', 'item': {'image': 'docker.io/pycontribs/centos:8', 'name': 'instance', 'pre_build_image': True}, 'ansible_loop_var': 'item', 'i': 0, 'ansible_index_var': 'i'}) 

    TASK [Determine the CMD directives] ********************************************
    ok: [localhost] => (item=None)
    ok: [localhost]

    TASK [Create molecule instance(s)] *********************************************
    changed: [localhost] => (item=None)
    changed: [localhost]

    TASK [Wait for instance(s) creation to complete] *******************************
    FAILED - RETRYING: Wait for instance(s) creation to complete (300 retries left).
    FAILED - RETRYING: Wait for instance(s) creation to complete (299 retries left).
    FAILED - RETRYING: Wait for instance(s) creation to complete (298 retries left).
    FAILED - RETRYING: Wait for instance(s) creation to complete (297 retries left).
    FAILED - RETRYING: Wait for instance(s) creation to complete (296 retries left).
    FAILED - RETRYING: Wait for instance(s) creation to complete (295 retries left).
    FAILED - RETRYING: Wait for instance(s) creation to complete (294 retries left).
    FAILED - RETRYING: Wait for instance(s) creation to complete (293 retries left).
    FAILED - RETRYING: Wait for instance(s) creation to complete (292 retries left).
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~中略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    FAILED - RETRYING: Wait for instance(s) creation to complete (256 retries left).
    FAILED - RETRYING: Wait for instance(s) creation to complete (255 retries left).
    FAILED - RETRYING: Wait for instance(s) creation to complete (254 retries left).
    changed: [localhost] => (item=None)
    changed: [localhost]

    PLAY RECAP *********************************************************************
    localhost                  : ok=5    changed=2    unreachable=0    failed=0    skipped=3    rescued=0    ignored=0

--> Scenario: 'default'
--> Action: 'prepare'
Skipping, prepare playbook not configured.
--> Scenario: 'default'
--> Action: 'converge'

    PLAY [Converge] ****************************************************************

    TASK [Gathering Facts] *********************************************************
[WARNING]: Unhandled error in Python interpreter discovery for host instance:
Expecting value: line 1 column 1 (char 0)
    ok: [instance]
[WARNING]: Platform linux on host instance is using the discovered Python
interpreter at /usr/bin/python3.6, but future installation of another Python
interpreter could change this. See https://docs.ansible.com/ansible/2.9/referen
ce_appendices/interpreter_discovery.html for more information.

    TASK [Include testmole] ********************************************************

    PLAY RECAP *********************************************************************
    instance                   : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

--> Scenario: 'default'
--> Action: 'idempotence'
Idempotence completed successfully.
--> Scenario: 'default'
--> Action: 'side_effect'
Skipping, side effect playbook not configured.
--> Scenario: 'default'
--> Action: 'verify'
--> Running Ansible Verifier

    PLAY [Verify] ******************************************************************

    TASK [Gathering Facts] *********************************************************
[WARNING]: Unhandled error in Python interpreter discovery for host instance:
Expecting value: line 1 column 1 (char 0)
    ok: [instance]
[WARNING]: Platform linux on host instance is using the discovered Python
interpreter at /usr/bin/python3.6, but future installation of another Python
interpreter could change this. See https://docs.ansible.com/ansible/2.9/referen
ce_appendices/interpreter_discovery.html for more information.

    TASK [Example assertion] *******************************************************
    ok: [instance] => {
        "changed": false,
        "msg": "All assertions passed"
    }

    PLAY RECAP *********************************************************************
    instance                   : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Verifier completed successfully.
--> Scenario: 'default'
--> Action: 'cleanup'
Skipping, cleanup playbook not configured.
--> Scenario: 'default'
--> Action: 'destroy'

    PLAY [Destroy] *****************************************************************

    TASK [Destroy molecule instance(s)] ********************************************
    changed: [localhost] => (item=None)
    changed: [localhost]

    TASK [Wait for instance(s) deletion to complete] *******************************
    FAILED - RETRYING: Wait for instance(s) deletion to complete (300 retries left).
    FAILED - RETRYING: Wait for instance(s) deletion to complete (299 retries left).
    changed: [localhost] => (item=None)
    changed: [localhost]

    PLAY RECAP *********************************************************************
    localhost                  : ok=2    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

--> Pruning extra files from scenario ephemeral directory

moleculeはシナリオと呼ばれるテスト前準備を含めた一連の処理を行います。先ほどのmolecule testで、冒頭にScenario: 'default'と呼ばれるものがそれにあたります。
シナリオの中にはステップと呼ばれるPlaybookに対するテストステップが行われています。
molecule testコマンドで以下のステップが順次実行されRoleのテストを行います。

ステップ 実施内容
dependency 依存関係を処理
lint 規約チェック
cleanup 環境の掃除
destroy テスト環境の削除
syntax 構文チェック
create テスト環境の構築
prepare テストの前処理を実行
converge Roleの実行
idempotence Roleの再実行
side_effect テスト実行のための副作用を発生させる
verify テストの実行
clenup 環境の掃除
destroy テスト環境の削除

※引用 SoftwareDesign 2020/6月号
(https://gihyo.jp/magazine/SD/archive/2020/202006)

テスト実装

今回はシンプルにCentOS 7と8のDockerコンテナにApacheを導入して、自動起動を有効化し起動できるというRoleを作成し、それに対してMoleculeでテストを行うということを検証していきます。まずは、練習も兼ねてtestmole/molecule/default/molucule.ymlを以下の内容に書き換えます。

molecule.yml
---
dependency:
  name: galaxy
driver:
  # ドライバ定義を指定する。Dockerなども指定可能
  name: podman
# ここでテスト対象となるドライバの詳細な定義を設定
platforms:
  - name: instance1
    image: docker.io/centos:7
    pre_build_image: true
    privileged: True
    command: /sbin/init
  - name: instance2
    image: docker.io/centos:8
    pre_build_image: true
    privileged: True
    command: /sbin/init
provisioner:
  name: ansible
verifier:
  name: ansible

このYAMLファイルでは、テスト対象となるコンテナの名前と起動イメージ、起動オプションを定義しております。先ほどのステップはmoleculeのサブコマンドとして用意されていますので、Roleを実行するmolecule convergeを実行します。(実行ディレクトリはロール名ディレクトリの直下です)

$ molecule converge
--> Test matrix

└── default
    ├── dependency
    ├── create
    ├── prepare
    └── converge

--> Scenario: 'default'
--> Action: 'dependency'
Skipping, missing the requirements file.
Skipping, missing the requirements file.
--> Scenario: 'default'
--> Action: 'create'
--> Sanity checks: 'podman'

    PLAY [Create] ******************************************************************

    TASK [Log into a container registry] *******************************************
    skipping: [localhost] => (item=None) 
    skipping: [localhost] => (item=None) 

    TASK [Check presence of custom Dockerfiles] ************************************
    ok: [localhost] => (item=None)
    ok: [localhost] => (item=None)
    ok: [localhost]

    TASK [Create Dockerfiles from image names] *************************************
    skipping: [localhost] => (item=None) 
    skipping: [localhost] => (item=None) 

    TASK [Discover local Podman images] ********************************************
    ok: [localhost] => (item=None)
    ok: [localhost] => (item=None)
    ok: [localhost]

    TASK [Build an Ansible compatible image] ***************************************
    skipping: [localhost] => (item={'changed': False, 'skipped': True, 'skip_reason': 'Conditional result was False', 'item': {'command': '/sbin/init', 'image': 'docker.io/pycontribs/centos:7', 'name': 'instance1', 'pre_build_image': True, 'privileged': True}, 'ansible_loop_var': 'item', 'i': 0, 'ansible_index_var': 'i'}) 
    skipping: [localhost] => (item={'changed': False, 'skipped': True, 'skip_reason': 'Conditional result was False', 'item': {'command': '/sbin/init', 'image': 'docker.io/pycontribs/centos:8', 'name': 'instalce2', 'pre_build_image': True, 'privileged': True}, 'ansible_loop_var': 'item', 'i': 1, 'ansible_index_var': 'i'}) 

    TASK [Determine the CMD directives] ********************************************
    ok: [localhost] => (item=None)
    ok: [localhost] => (item=None)
    ok: [localhost]

    TASK [Create molecule instance(s)] *********************************************
    changed: [localhost] => (item=None)
    changed: [localhost] => (item=None)
    changed: [localhost]

    TASK [Wait for instance(s) creation to complete] *******************************
    FAILED - RETRYING: Wait for instance(s) creation to complete (300 retries left).
    FAILED - RETRYING: Wait for instance(s) creation to complete (299 retries left).
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~中略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    FAILED - RETRYING: Wait for instance(s) creation to complete (269 retries left).
    FAILED - RETRYING: Wait for instance(s) creation to complete (268 retries left).
    changed: [localhost] => (item=None)
    changed: [localhost] => (item=None)
    changed: [localhost]

    PLAY RECAP *********************************************************************
    localhost                  : ok=5    changed=2    unreachable=0    failed=0    skipped=3    rescued=0    ignored=0

--> Scenario: 'default'
--> Action: 'prepare'
Skipping, prepare playbook not configured.
--> Scenario: 'default'
--> Action: 'converge'

    PLAY [Converge] ****************************************************************

    TASK [Gathering Facts] *********************************************************
[WARNING]: Unhandled error in Python interpreter discovery for host instance1:
Expecting value: line 1 column 1 (char 0)
[WARNING]: Unhandled error in Python interpreter discovery for host instalce2:
Expecting value: line 1 column 1 (char 0)
    ok: [instance1]
[WARNING]: Platform linux on host instance1 is using the discovered Python
interpreter at /usr/bin/python, but future installation of another Python
interpreter could change this. See https://docs.ansible.com/ansible/2.9/referen
ce_appendices/interpreter_discovery.html for more information.
    ok: [instalce2]
[WARNING]: Platform linux on host instalce2 is using the discovered Python
interpreter at /usr/bin/python3.6, but future installation of another Python
interpreter could change this. See https://docs.ansible.com/ansible/2.9/referen
ce_appendices/interpreter_discovery.html for more information.

    TASK [Include testmole] ********************************************************

    PLAY RECAP *********************************************************************
    instalce2                  : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
    instance1                  : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

molecule listを実行すれば起動したテスト環境の状態が確認できます。

$ molecule list
Instance Name    Driver Name    Provisioner Name    Scenario Name    Created    Converged
---------------  -------------  ------------------  ---------------  ---------  -----------
instance1        podman         ansible             default          true       true
instalce2        podman         ansible             default          true       true

削除はmolecule destoryで行えます。

$ molecule destroy
--> Test matrix

└── default
    ├── dependency
    ├── cleanup
    └── destroy

--> Scenario: 'default'
--> Action: 'dependency'
Skipping, missing the requirements file.
Skipping, missing the requirements file.
--> Scenario: 'default'
--> Action: 'cleanup'
Skipping, cleanup playbook not configured.
--> Scenario: 'default'
--> Action: 'destroy'
--> Sanity checks: 'podman'

    PLAY [Destroy] *****************************************************************

    TASK [Destroy molecule instance(s)] ********************************************
    changed: [localhost] => (item=None)
    changed: [localhost] => (item=None)
    changed: [localhost]

    TASK [Wait for instance(s) deletion to complete] *******************************
    changed: [localhost] => (item=None)
    changed: [localhost] => (item=None)
    changed: [localhost]

    PLAY RECAP *********************************************************************
    localhost                  : ok=2    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

--> Pruning extra files from scenario ephemeral directory

さて、ここからは本命となるテストをverify.ymlに記述していきます。以下の内容をtestmole/molecule/default/verify.ymlに記述していきます。

verify.yml
---
# This is httpd install playbook to execute Ansible tests.

- name: Verify
  hosts: all
  tasks:
  - ignore_errors: yes
    block:
    - name: httpdパッケージの存在を確認する
      yum:
        list: httpd
      register: result_rpm

    - name: httpdプロセスが起動していることを確認する
      shell: ps -ef | grep http[d]
      register: result_proc

    - name: httpdサービスが自動起動になっているかを確認する
      shell: systemctl is-enabled httpd
      register: result_enabled

  - name: 結果をまとめて確認する
    assert:
      that: "{{ result.failed == false }}"
    loop:
      - "{{ result_rpm }}"
      - "{{ result_proc }}"
      - "{{ result_enabled }}"
    loop_control:
      loop_var: result

これで完成かと思い、molecule testを実行してみますと以下のエラーが出てくると思います。

$ molecule test
--> Test matrix

└── default
    ├── dependency
    ├── lint
    ├── cleanup
    ├── destroy
    ├── syntax
    ├── create
    ├── prepare
    ├── converge
    ├── idempotence
    ├── side_effect
    ├── verify
    ├── cleanup
    └── destroy

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~中略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    PLAY [Verify] ******************************************************************

    TASK [Gathering Facts] *********************************************************
[WARNING]: Unhandled error in Python interpreter discovery for host instalce2:
Expecting value: line 1 column 1 (char 0)
[WARNING]: Unhandled error in Python interpreter discovery for host instance1:
Expecting value: line 1 column 1 (char 0)
    ok: [instance1]
[WARNING]: Platform linux on host instance1 is using the discovered Python
interpreter at /usr/bin/python, but future installation of another Python
interpreter could change this. See https://docs.ansible.com/ansible/2.9/referen
ce_appendices/interpreter_discovery.html for more information.
    ok: [instalce2]
[WARNING]: Platform linux on host instalce2 is using the discovered Python
interpreter at /usr/bin/python3.6, but future installation of another Python
interpreter could change this. See https://docs.ansible.com/ansible/2.9/referen
ce_appendices/interpreter_discovery.html for more information.

    TASK [httpdパッケージの存在を確認する] ******************************************************
    ok: [instance1]
    ok: [instalce2]

    TASK [httpdプロセスが起動していることを確認する] *************************************************
    ...ignoring
fatal: [instalce2]: FAILED! => {"changed": true, "cmd": "ps -ef | grep http[d]", "delta": "0:00:00.378992", "end": "2020-10-18 14:08:21.534917", "msg": "non-zero return code", "rc": 1, "start": "2020-10-18 14:08:21.155925", "stderr": "", "stderr_lines": [], "stdout": "", "stdout_lines": []}
    ...ignoring
fatal: [instance1]: FAILED! => {"changed": true, "cmd": "ps -ef | grep http[d]", "delta": "0:00:00.432263", "end": "2020-10-18 14:08:21.944257", "msg": "non-zero return code", "rc": 1, "start": "2020-10-18 14:08:21.511994", "stderr": "", "stderr_lines": [], "stdout": "", "stdout_lines": []}

    TASK [httpdサービスが自動起動になっているかを確認する] **********************************************
    ...ignoring
fatal: [instance1]: FAILED! => {"changed": true, "cmd": "systemctl is-enabled httpd", "delta": "0:00:00.379746", "end": "2020-10-18 14:08:27.254545", "msg": "non-zero return code", "rc": 1, "start": "2020-10-18 14:08:26.874799", "stderr": "Failed to get unit file state for httpd.service: No such file or directory", "stderr_lines": ["Failed to get unit file state for httpd.service: No such file or directory"], "stdout": "", "stdout_lines": []}
    ...ignoring
fatal: [instalce2]: FAILED! => {"changed": true, "cmd": "systemctl is-enabled httpd", "delta": "0:00:00.255231", "end": "2020-10-18 14:08:27.240254", "msg": "non-zero return code", "rc": 1, "start": "2020-10-18 14:08:26.985023", "stderr": "Failed to get unit file state for httpd.service: No such file or directory", "stderr_lines": ["Failed to get unit file state for httpd.service: No such file or directory"], "stdout": "", "stdout_lines": []}

    TASK [結果をまとめて確認する] *************************************************************
    ok: [instance1] => (item={'results': [{'envra': '0:httpd-2.4.6-93.el7.centos.x86_64', 'name': 'httpd', 'repo': 'base', 'epoch': '0', 'version': '2.4.6', 'release': '93.el7.centos', 'yumstate': 'available', 'arch': 'x86_64'}], 'failed': False, 'changed': False}) => {
        "ansible_loop_var": "result",
        "changed": false,
        "msg": "All assertions passed",
        "result": {
            "changed": false,
            "failed": false,
            "results": [
                {
                    "arch": "x86_64",
                    "envra": "0:httpd-2.4.6-93.el7.centos.x86_64",
                    "epoch": "0",
                    "name": "httpd",
                    "release": "93.el7.centos",
                    "repo": "base",
                    "version": "2.4.6",
                    "yumstate": "available"
                }
            ]
        }
    }
    ok: [instalce2] => (item={'msg': '', 'results': [{'name': 'httpd', 'arch': 'x86_64', 'epoch': '0', 'release': '21.module_el8.2.0+494+1df74eae', 'version': '2.4.37', 'repo': 'AppStream', 'nevra': '0:httpd-2.4.37-21.module_el8.2.0+494+1df74eae.x86_64', 'yumstate': 'available'}], 'failed': False, 'changed': False}) => {
        "ansible_loop_var": "result",
        "changed": false,
        "msg": "All assertions passed",
        "result": {
            "changed": false,
            "failed": false,
            "msg": "",
            "results": [
                {
                    "arch": "x86_64",
                    "epoch": "0",
                    "name": "httpd",
                    "nevra": "0:httpd-2.4.37-21.module_el8.2.0+494+1df74eae.x86_64",
                    "release": "21.module_el8.2.0+494+1df74eae",
                    "repo": "AppStream",
                    "version": "2.4.37",
                    "yumstate": "available"
                }
            ]
        }
    }
    failed: [instalce2] (item={'msg': 'non-zero return code', 'cmd': 'ps -ef | grep http[d]', 'stdout': '', 'stderr': '', 'rc': 1, 'start': '2020-10-18 14:08:21.155925', 'end': '2020-10-18 14:08:21.534917', 'delta': '0:00:00.378992', 'changed': True, 'failed': True, 'stdout_lines': [], 'stderr_lines': []}) => {
        "ansible_loop_var": "result",
        "assertion": false,
        "changed": false,
        "evaluated_to": false,
        "msg": "Assertion failed",
        "result": {
            "changed": true,
            "cmd": "ps -ef | grep http[d]",
            "delta": "0:00:00.378992",
            "end": "2020-10-18 14:08:21.534917",
            "failed": true,
            "msg": "non-zero return code",
            "rc": 1,
            "start": "2020-10-18 14:08:21.155925",
            "stderr": "",
            "stderr_lines": [],
            "stdout": "",
            "stdout_lines": []
        }
    }
    failed: [instance1] (item={'changed': True, 'end': '2020-10-18 14:08:21.944257', 'stdout': '', 'cmd': 'ps -ef | grep http[d]', 'failed': True, 'delta': '0:00:00.432263', 'stderr': '', 'rc': 1, 'start': '2020-10-18 14:08:21.511994', 'msg': 'non-zero return code', 'stdout_lines': [], 'stderr_lines': []}) => {
        "ansible_loop_var": "result",
        "assertion": false,
        "changed": false,
        "evaluated_to": false,
        "msg": "Assertion failed",
        "result": {
            "changed": true,
            "cmd": "ps -ef | grep http[d]",
            "delta": "0:00:00.432263",
            "end": "2020-10-18 14:08:21.944257",
            "failed": true,
            "msg": "non-zero return code",
            "rc": 1,
            "start": "2020-10-18 14:08:21.511994",
            "stderr": "",
            "stderr_lines": [],
            "stdout": "",
            "stdout_lines": []
        }
    }
    failed: [instance1] (item={'changed': True, 'end': '2020-10-18 14:08:27.254545', 'stdout': '', 'cmd': 'systemctl is-enabled httpd', 'failed': True, 'delta': '0:00:00.379746', 'stderr': 'Failed to get unit file state for httpd.service: No such file or directory', 'rc': 1, 'start': '2020-10-18 14:08:26.874799', 'msg': 'non-zero return code', 'stdout_lines': [], 'stderr_lines': ['Failed to get unit file state for httpd.service: No such file or directory']}) => {
        "ansible_loop_var": "result",
        "assertion": false,
        "changed": false,
        "evaluated_to": false,
        "msg": "Assertion failed",
        "result": {
            "changed": true,
            "cmd": "systemctl is-enabled httpd",
            "delta": "0:00:00.379746",
            "end": "2020-10-18 14:08:27.254545",
            "failed": true,
            "msg": "non-zero return code",
            "rc": 1,
            "start": "2020-10-18 14:08:26.874799",
            "stderr": "Failed to get unit file state for httpd.service: No such file or directory",
            "stderr_lines": [
                "Failed to get unit file state for httpd.service: No such file or directory"
            ],
            "stdout": "",
            "stdout_lines": []
        }
    }
    failed: [instalce2] (item={'msg': 'non-zero return code', 'cmd': 'systemctl is-enabled httpd', 'stdout': '', 'stderr': 'Failed to get unit file state for httpd.service: No such file or directory', 'rc': 1, 'start': '2020-10-18 14:08:26.985023', 'end': '2020-10-18 14:08:27.240254', 'delta': '0:00:00.255231', 'changed': True, 'failed': True, 'stdout_lines': [], 'stderr_lines': ['Failed to get unit file state for httpd.service: No such file or directory']}) => {
        "ansible_loop_var": "result",
        "assertion": false,
        "changed": false,
        "evaluated_to": false,
        "msg": "Assertion failed",
        "result": {
            "changed": true,
            "cmd": "systemctl is-enabled httpd",
            "delta": "0:00:00.255231",
            "end": "2020-10-18 14:08:27.240254",
            "failed": true,
            "msg": "non-zero return code",
            "rc": 1,
            "start": "2020-10-18 14:08:26.985023",
            "stderr": "Failed to get unit file state for httpd.service: No such file or directory",
            "stderr_lines": [
                "Failed to get unit file state for httpd.service: No such file or directory"
            ],
            "stdout": "",
            "stdout_lines": []
        }
    }

    PLAY RECAP *********************************************************************
    instalce2                  : ok=4    changed=2    unreachable=0    failed=1    skipped=0    rescued=0    ignored=2   
    instance1                  : ok=4    changed=2    unreachable=0    failed=1    skipped=0    rescued=0    ignored=2   

ERROR: 
An error occurred during the test sequence action: 'verify'. Cleaning up.
--> Scenario: 'default'
--> Action: 'cleanup'
Skipping, cleanup playbook not configured.
--> Scenario: 'default'
--> Action: 'destroy'

    PLAY [Destroy] *****************************************************************

    TASK [Destroy molecule instance(s)] ********************************************
    changed: [localhost] => (item=None)
    changed: [localhost] => (item=None)
    changed: [localhost]

    TASK [Wait for instance(s) deletion to complete] *******************************
    changed: [localhost] => (item=None)
    changed: [localhost] => (item=None)
    changed: [localhost]

    PLAY RECAP *********************************************************************
    localhost                  : ok=2    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

--> Pruning extra files from scenario ephemeral directory

途中のエラーはRoleの本体がないため仕様が満たせていないことを意味します。testmole/tasks/main.ymlにタスクを記載します。

main.yml
---
# tasks file for testmole
- name: install httpd package
  yum:
    name: httpd
    state: latest

- name: start httpd
  systemd:
    name: httpd
    state: started
    enabled: yes

ここまでできましたら、molecule testでテストを実行してみます。

$ molecule test
--> Test matrix

└── default
    ├── dependency
    ├── lint
    ├── cleanup
    ├── destroy
    ├── syntax
    ├── create
    ├── prepare
    ├── converge
    ├── idempotence
    ├── side_effect
    ├── verify
    ├── cleanup
    └── destroy

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~中略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    PLAY [Verify] ******************************************************************

    TASK [Gathering Facts] *********************************************************
[WARNING]: Unhandled error in Python interpreter discovery for host instance1:
Expecting value: line 1 column 1 (char 0)
[WARNING]: Unhandled error in Python interpreter discovery for host instalce2:
Expecting value: line 1 column 1 (char 0)
    ok: [instance1]
[WARNING]: Platform linux on host instance1 is using the discovered Python
interpreter at /usr/bin/python, but future installation of another Python
interpreter could change this. See https://docs.ansible.com/ansible/2.9/referen
ce_appendices/interpreter_discovery.html for more information.
    ok: [instalce2]
[WARNING]: Platform linux on host instalce2 is using the discovered Python
interpreter at /usr/bin/python3.6, but future installation of another Python
interpreter could change this. See https://docs.ansible.com/ansible/2.9/referen
ce_appendices/interpreter_discovery.html for more information.

    TASK [httpdパッケージの存在を確認する] ******************************************************
    ok: [instalce2]
    ok: [instance1]

    TASK [httpdプロセスが起動していることを確認する] *************************************************
    changed: [instance1]
    changed: [instalce2]

    TASK [httpdサービスが自動起動になっているかを確認する] **********************************************
    changed: [instance1]
    changed: [instalce2]

    TASK [結果をまとめて確認する] *************************************************************
    ok: [instalce2] => (item={'msg': '', 'results': [{'name': 'httpd', 'arch': 'x86_64', 'epoch': '0', 'release': '21.module_el8.2.0+494+1df74eae', 'version': '2.4.37', 'repo': '@System', 'nevra': '0:httpd-2.4.37-21.module_el8.2.0+494+1df74eae.x86_64', 'yumstate': 'installed'}, {'name': 'httpd', 'arch': 'x86_64', 'epoch': '0', 'release': '21.module_el8.2.0+494+1df74eae', 'version': '2.4.37', 'repo': 'AppStream', 'nevra': '0:httpd-2.4.37-21.module_el8.2.0+494+1df74eae.x86_64', 'yumstate': 'available'}], 'failed': False, 'changed': False}) => {
        "ansible_loop_var": "result",
        "changed": false,
        "msg": "All assertions passed",
        "result": {
            "changed": false,
            "failed": false,
            "msg": "",
            "results": [
                {
                    "arch": "x86_64",
                    "epoch": "0",
                    "name": "httpd",
                    "nevra": "0:httpd-2.4.37-21.module_el8.2.0+494+1df74eae.x86_64",
                    "release": "21.module_el8.2.0+494+1df74eae",
                    "repo": "@System",
                    "version": "2.4.37",
                    "yumstate": "installed"
                },
                {
                    "arch": "x86_64",
                    "epoch": "0",
                    "name": "httpd",
                    "nevra": "0:httpd-2.4.37-21.module_el8.2.0+494+1df74eae.x86_64",
                    "release": "21.module_el8.2.0+494+1df74eae",
                    "repo": "AppStream",
                    "version": "2.4.37",
                    "yumstate": "available"
                }
            ]
        }
    }
    ok: [instalce2] => (item={'cmd': 'ps -ef | grep http[d]', 'stdout': 'root         278       1  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       279     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       280     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       281     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       282     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND', 'stderr': '', 'rc': 0, 'start': '2020-10-18 14:20:51.280186', 'end': '2020-10-18 14:20:51.452977', 'delta': '0:00:00.172791', 'changed': True, 'stdout_lines': ['root         278       1  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND', 'apache       279     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND', 'apache       280     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND', 'apache       281     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND', 'apache       282     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND'], 'stderr_lines': [], 'failed': False}) => {
        "ansible_loop_var": "result",
        "changed": false,
        "msg": "All assertions passed",
        "result": {
            "changed": true,
            "cmd": "ps -ef | grep http[d]",
            "delta": "0:00:00.172791",
            "end": "2020-10-18 14:20:51.452977",
            "failed": false,
            "rc": 0,
            "start": "2020-10-18 14:20:51.280186",
            "stderr": "",
            "stderr_lines": [],
            "stdout": "root         278       1  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       279     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       280     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       281     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       282     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND",
            "stdout_lines": [
                "root         278       1  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND",
                "apache       279     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND",
                "apache       280     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND",
                "apache       281     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND",
                "apache       282     278  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND"
            ]
        }
    }
    ok: [instalce2] => (item={'cmd': 'systemctl is-enabled httpd', 'stdout': 'enabled', 'stderr': '', 'rc': 0, 'start': '2020-10-18 14:20:56.569809', 'end': '2020-10-18 14:20:56.595759', 'delta': '0:00:00.025950', 'changed': True, 'stdout_lines': ['enabled'], 'stderr_lines': [], 'failed': False}) => {
        "ansible_loop_var": "result",
        "changed": false,
        "msg": "All assertions passed",
        "result": {
            "changed": true,
            "cmd": "systemctl is-enabled httpd",
            "delta": "0:00:00.025950",
            "end": "2020-10-18 14:20:56.595759",
            "failed": false,
            "rc": 0,
            "start": "2020-10-18 14:20:56.569809",
            "stderr": "",
            "stderr_lines": [],
            "stdout": "enabled",
            "stdout_lines": [
                "enabled"
            ]
        }
    }
    ok: [instance1] => (item={'results': [{'envra': '0:httpd-2.4.6-93.el7.centos.x86_64', 'name': 'httpd', 'repo': 'base', 'epoch': '0', 'version': '2.4.6', 'release': '93.el7.centos', 'yumstate': 'available', 'arch': 'x86_64'}, {'envra': '0:httpd-2.4.6-93.el7.centos.x86_64', 'name': 'httpd', 'repo': 'installed', 'epoch': '0', 'version': '2.4.6', 'release': '93.el7.centos', 'yumstate': 'installed', 'arch': 'x86_64'}], 'failed': False, 'changed': False}) => {
        "ansible_loop_var": "result",
        "changed": false,
        "msg": "All assertions passed",
        "result": {
            "changed": false,
            "failed": false,
            "results": [
                {
                    "arch": "x86_64",
                    "envra": "0:httpd-2.4.6-93.el7.centos.x86_64",
                    "epoch": "0",
                    "name": "httpd",
                    "release": "93.el7.centos",
                    "repo": "base",
                    "version": "2.4.6",
                    "yumstate": "available"
                },
                {
                    "arch": "x86_64",
                    "envra": "0:httpd-2.4.6-93.el7.centos.x86_64",
                    "epoch": "0",
                    "name": "httpd",
                    "release": "93.el7.centos",
                    "repo": "installed",
                    "version": "2.4.6",
                    "yumstate": "installed"
                }
            ]
        }
    }
    ok: [instance1] => (item={'changed': True, 'end': '2020-10-18 14:20:51.225378', 'stdout': 'root         337       1  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       338     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       339     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       340     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       341     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       342     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND', 'cmd': 'ps -ef | grep http[d]', 'rc': 0, 'start': '2020-10-18 14:20:50.978546', 'stderr': '', 'delta': '0:00:00.246832', 'stdout_lines': ['root         337       1  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND', 'apache       338     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND', 'apache       339     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND', 'apache       340     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND', 'apache       341     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND', 'apache       342     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND'], 'stderr_lines': [], 'failed': False}) => {
        "ansible_loop_var": "result",
        "changed": false,
        "msg": "All assertions passed",
        "result": {
            "changed": true,
            "cmd": "ps -ef | grep http[d]",
            "delta": "0:00:00.246832",
            "end": "2020-10-18 14:20:51.225378",
            "failed": false,
            "rc": 0,
            "start": "2020-10-18 14:20:50.978546",
            "stderr": "",
            "stderr_lines": [],
            "stdout": "root         337       1  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       338     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       339     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       340     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       341     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND\napache       342     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND",
            "stdout_lines": [
                "root         337       1  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND",
                "apache       338     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND",
                "apache       339     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND",
                "apache       340     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND",
                "apache       341     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND",
                "apache       342     337  0 14:19 ?        00:00:00 /usr/sbin/httpd -DFOREGROUND"
            ]
        }
    }
    ok: [instance1] => (item={'changed': True, 'end': '2020-10-18 14:20:56.498353', 'stdout': 'enabled', 'cmd': 'systemctl is-enabled httpd', 'rc': 0, 'start': '2020-10-18 14:20:56.467774', 'stderr': '', 'delta': '0:00:00.030579', 'stdout_lines': ['enabled'], 'stderr_lines': [], 'failed': False}) => {
        "ansible_loop_var": "result",
        "changed": false,
        "msg": "All assertions passed",
        "result": {
            "changed": true,
            "cmd": "systemctl is-enabled httpd",
            "delta": "0:00:00.030579",
            "end": "2020-10-18 14:20:56.498353",
            "failed": false,
            "rc": 0,
            "start": "2020-10-18 14:20:56.467774",
            "stderr": "",
            "stderr_lines": [],
            "stdout": "enabled",
            "stdout_lines": [
                "enabled"
            ]
        }
    }

    PLAY RECAP *********************************************************************
    instalce2                  : ok=5    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
    instance1                  : ok=5    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Verifier completed successfully.
--> Scenario: 'default'
--> Action: 'cleanup'
Skipping, cleanup playbook not configured.
--> Scenario: 'default'
--> Action: 'destroy'

    PLAY [Destroy] *****************************************************************

    TASK [Destroy molecule instance(s)] ********************************************
    changed: [localhost] => (item=None)
    changed: [localhost] => (item=None)
    changed: [localhost]

    TASK [Wait for instance(s) deletion to complete] *******************************
    changed: [localhost] => (item=None)
    FAILED - RETRYING: Wait for instance(s) deletion to complete (300 retries left).
    changed: [localhost] => (item=None)
    changed: [localhost]

    PLAY RECAP *********************************************************************
    localhost                  : ok=2    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

--> Pruning extra files from scenario ephemeral directory

エラーがなくテストが問題なく完了していることが確認できます。
(最後にテスト環境は破棄されていますので、molecule listを実行しますとCreatedがfalseになっていることがわかります)

$ molecule list
Instance Name    Driver Name    Provisioner Name    Scenario Name    Created    Converged
---------------  -------------  ------------------  ---------------  ---------  -----------
instance1        podman         ansible             default          false      false
instalce2        podman         ansible             default          false      false

所感

今回は、Moleculeを単独で使ってAnsible Roleのテストを行いました。これをGitに登録してCIツールと連携させればテストの自動化もできます。
AnsibleのRoleは何度か修正や改訂を行う機会が多く、そのたびに人の手で確認するのは大変なので、Moleculeを使って、インフラ構築もテスト自動化できるように研究を続けていきたいと思います。