diff --git a/.flake8 b/.flake8
deleted file mode 100644
index 151776749324426b3977b54ef503242ac51be0a1..0000000000000000000000000000000000000000
--- a/.flake8
+++ /dev/null
@@ -1,2 +0,0 @@
-[flake8]
-exclude = ldap/ldap_attr.py
diff --git a/.gitignore b/.gitignore
index fc640611fc51ca802434de7d3e5780185d450de9..2a7d2ce42bbb328025f6fdd4b0a1a4596606b87a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
 *~
 ~*
 *.pyc
+tests/roles
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 40017a3ddfc3e409d640ab8536097de55d685c7c..8986c29e9208b63ddcd7aaba215a8a7b6d9a312e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,21 +1,24 @@
 -   repo: git://github.com/pre-commit/pre-commit-hooks
-    sha: 97b88d9610bcc03982ddac33caba98bb2b751f5f
+    sha: v0.8.0
     hooks:
     -   id: check-added-large-files
     -   id: check-yaml
     -   id: check-merge-conflict
     -   id: flake8
 -   repo: https://github.com/adarnimrod/shell-pre-commit
-    sha: v0.1.0
+    sha: v0.5.4
     hooks:
     -   id: shell-lint
-        files: collectd/collectd_facts|nginx/nginx_facts|ssl/dhparams
+        files: &shellscripts collectd/collectd_facts|nginx/nginx_facts|ssl/dhparams
+    -   id: shellcheck
+        files: *shellscripts
 -   repo: https://github.com/adarnimrod/ansible-pre-commit.git
-    sha: v0.4.0
+    sha: v0.6.0
     hooks:
     -   id: ansible-syntax-check
+        files: &playbook tests/playbook.yaml
 -   repo: https://github.com/willthames/ansible-lint
-    sha: 959ab0f525e9abb19cf75f34381015cf33695f61
+    sha: v3.4.13
     hooks:
     -   id: ansible-lint
-        files: playbook.yml
+        files: *playbook
diff --git a/.travis.yml b/.travis.yml
index 9287db990c7b4ba133e6a2e5e0a6a2a547a231f3..1073338a4bc1edcebb54e845cc4609c484c8233a 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,28 +1,50 @@
 ---
 language: python
-python: "2.7"
+python:
+    - "2.7"
 dist: trusty
 sudo: false
-services: [docker]
+services:
+    - docker
+group: beta
 cache:
   - pip
   - directories:
       - $HOME/.pre-commit
 
 env:
-  - DOCKER=ubuntu:trusty
-  - DOCKER=ubuntu:xenial
-  - DOCKER=debian:jessie
+- TOXENV: pre-commit
+- TOXENV: ansible2.3.1.0-image_ubuntu_xenial
+- TOXENV: ansible2.3.1.0-image_ubuntu_trusty
+- TOXENV: ansible2.3.1.0-image_ubuntu_precise
+- TOXENV: ansible2.3.1.0-image_debian_stretch
+- TOXENV: ansible2.3.1.0-image_debian_jessie
+- TOXENV: ansible2.3.1.0-image_debian_wheezy
+- TOXENV: ansible2.2.3.0-image_ubuntu_xenial
+- TOXENV: ansible2.2.3.0-image_ubuntu_trusty
+- TOXENV: ansible2.2.3.0-image_ubuntu_precise
+- TOXENV: ansible2.2.3.0-image_debian_stretch
+- TOXENV: ansible2.2.3.0-image_debian_jessie
+- TOXENV: ansible2.2.3.0-image_debian_wheezy
+- TOXENV: ansible2.1.6.0-image_ubuntu_xenial
+- TOXENV: ansible2.1.6.0-image_ubuntu_trusty
+- TOXENV: ansible2.1.6.0-image_ubuntu_precise
+- TOXENV: ansible2.1.6.0-image_debian_stretch
+- TOXENV: ansible2.1.6.0-image_debian_jessie
+- TOXENV: ansible2.1.6.0-image_debian_wheezy
+- TOXENV: ansible2.0.2.0-image_ubuntu_xenial
+- TOXENV: ansible2.0.2.0-image_ubuntu_trusty
+- TOXENV: ansible2.0.2.0-image_ubuntu_precise
+- TOXENV: ansible2.0.2.0-image_debian_stretch
+- TOXENV: ansible2.0.2.0-image_debian_jessie
+- TOXENV: ansible2.0.2.0-image_debian_wheezy
 
 install:
-  - pip install pre_commit ansible | cat
-
-before_script:
-  - docker run --detach --name $(echo $DOCKER | sed 's/:/_/g') $DOCKER tail -f /.dockerenv
+  - pip install tox-travis | cat
 
 script:
-  - pre-commit run --all-files
-  - ansible-playbook -i $(echo $DOCKER | sed 's/:/_/g'), -c docker -vv playbook.yml
+  - tox
 
 notifications:
   email: false
+  on_failure: never
diff --git a/README.rst b/README.rst
index ae74c91f35e84d6ee089349bac9cbbbaeebe2b9d..3c083eab058682547fa977ab131e5d07c7eb713b 100644
--- a/README.rst
+++ b/README.rst
@@ -35,6 +35,11 @@ Modules
 - nginx_facts
 - dhparams
 
+Usage
+-----
+
+See example usage in the test playbooks under :code:`tests/`.
+
 License
 -------
 
@@ -44,7 +49,12 @@ This software is licensed under the AGPL v3+ license (see the
 Testing
 -------
 
-Currently the only tests are `pre-commit <http://www.pre-commit.com/>`_ hooks.
+Modules are tested on Ubuntu Precise, Trusty and Xenial and Debian Wheezy,
+Jessie and Stretch with Ansible version 2.0.2.0, 2.1.6.0, 2.2.3.0 and 2.3.1.0
+in `TravisCI <https://travis-ci.org/adarnimrod/ansible-modules>`_. To tests
+require `Tox <https://tox.readthedocs.io/>`_ and `Docker
+<https://docker.com>`_. `Pre-commit <http://pre-commit.com/>`_ is also setup
+for this project.
 
 Author
 ------
diff --git a/ansible.cfg b/ansible.cfg
deleted file mode 100644
index b6d3a7e457b49292d5795a17fee30f145ac009bc..0000000000000000000000000000000000000000
--- a/ansible.cfg
+++ /dev/null
@@ -1,3 +0,0 @@
-[defaults]
-library = ./
-host_key_checking = False
diff --git a/collectd/collectd_facts b/collectd/collectd_facts
index 680a42e856afd7d6acaa22fcf17de1b7a723e059..67151ebbc50df88627a41fa3ec296731c0d01530 100755
--- a/collectd/collectd_facts
+++ b/collectd/collectd_facts
@@ -1,4 +1,5 @@
 #!/bin/sh -e
+# shellcheck disable=SC1090
 . "$1"
 
 fail ()
@@ -7,6 +8,6 @@ fail ()
     exit
 }
 
-which collectd 2>&1 > /dev/null || fail "Can't find collectd executable."
+which collectd > /dev/null 2>&1 || fail "Can't find collectd executable."
 
 collectd -h 2>&1 | sed -n 's/[a-zA-Z ]*\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/{"changed": false, "ansible_facts": {"collectd": {"major":\1, "minor":\2, "patch":\3, "version":"\1.\2.\3"}}}/p'
diff --git a/nginx/nginx_facts b/nginx/nginx_facts
index 1c37c9b657ef66823978ddb0d8bf92172f7c1d4c..7e1da372c95208ed38d73a2caa99931e65876326 100755
--- a/nginx/nginx_facts
+++ b/nginx/nginx_facts
@@ -1,4 +1,5 @@
 #!/bin/sh -e
+# shellcheck disable=SC1090
 . "$1"
 
 fail ()
@@ -7,6 +8,6 @@ fail ()
     exit
 }
 
-which nginx 2>&1 > /dev/null || fail "Can't find nginx executable."
+which nginx > /dev/null 2>&1 || fail "Can't find nginx executable."
 
 nginx -v 2>&1 | sed -n 's/[a-zA-Z :\/]*\([0-9]*\)\.\([0-9]*\)\.\([0-9]\)*.*$/{"changed": false, "ansible_facts": {"nginx": {"major":\1, "minor":\2, "patch":\3, "version":"\1.\2.\3"}}}/gp'
diff --git a/playbook.yml b/playbook.yml
deleted file mode 100644
index 64b98b32a7b9665494315a4e601d089ae343a173..0000000000000000000000000000000000000000
--- a/playbook.yml
+++ /dev/null
@@ -1,97 +0,0 @@
----
-- hosts: all
-  gather_facts: False
-  tasks:
-  - name: Update APT sources
-    raw: DEBIAN_FRONTEND=noninteractive apt-get update
-    changed_when: False
-
-  - name: APT install Python
-    raw: DEBIAN_FRONTEND=noninteractive apt-get install -qy python2.7 python
-    register: debian_bootstrap_install_python
-    changed_when: "'Unpacking' in debian_bootstrap_install_python.stdout"
-
-  - name: Gather facts
-    setup:
-
-  - name: APT install
-    apt:
-      name: ['nginx-light', 'collectd-core', 'openssl']
-      state: present
-      install_recommends: no
-
-  - name: Collectd facts
-    collectd_facts:
-    register: collectd_facts
-
-  - name: Debug
-    debug:
-      var: collectd_facts
-      verbosity: 2
-
-  - name: Assertions
-    assert:
-      that:
-      - collectd_facts|changed == False
-      - collectd.major is number
-      - collectd.minor is number
-      - collectd.patch is number
-      - collectd.version is defined
-
-  - name: Nginx facts
-    nginx_facts:
-    register: nginx_facts
-
-  - name: Debug
-    debug:
-      var: nginx_facts
-      verbosity: 2
-
-  - name: Assertions
-    assert:
-      that:
-      - nginx_facts|changed == False
-      - nginx.major is number
-      - nginx.minor is number
-      - nginx.patch is number
-      - nginx.version is defined
-
-  - name: DH params for missing file
-    ignore_errors: True
-    dhparams:
-      path: /etc/ssl/dhparams.pem
-    register: missing_dhparams
-
-  - name: Debug
-    debug:
-      var: missing_dhparams
-      verbosity: 2
-
-  - name: Assertions
-    assert:
-      that:
-      - missing_dhparams.bits == 0
-      - missing_dhparams|failed == True
-      - missing_dhparams|changed == False
-
-  - name: Generate DH params
-    command: openssl dhparam -out /etc/ssl/dhparams.pem 2048
-    changed_when: True
-
-  - name: DH params for existing file
-    dhparams:
-      path: /etc/ssl/dhparams.pem
-    register: existing_dhparams
-
-  - name: Debug
-    debug:
-      var: existing_dhparams
-      verbosity: 2
-
-  - name: Assertions
-    assert:
-      that:
-      - existing_dhparams.bits == 2048
-      - existing_dhparams|failed == False
-      - existing_dhparams|changed == False
-      - existing_dhparams.path == '/etc/ssl/dhparams.pem'
diff --git a/ssl/dhparams b/ssl/dhparams
index 277936c9d2a844fe294e19c23e4d2c655fc30ee9..c50a1b81559efc728eff3598cc12ea38de3b8f4d 100755
--- a/ssl/dhparams
+++ b/ssl/dhparams
@@ -1,4 +1,5 @@
 #!/bin/sh -e
+# shellcheck disable=SC1090
 . "$1"
 
 fail ()
@@ -7,11 +8,12 @@ fail ()
     exit
 }
 
+# shellcheck disable=SC2154
 test -z "$path" && fail "Parameter 'path' is not set."
 test ! -r "$path" && fail "Can't access 'path'."
 test ! -f "$path" && fail "Parameter 'path' doesn't exists or is not a file."
 
-bits="$(openssl dhparam -in $path -text -noout \
+bits="$(openssl dhparam -in "$path" -text -noout \
     | sed -n 's/[a-zA-Z#0-9 \-]*Parameters: (\([0-9]*\) bit)/\1/p')"
 
 echo "{ \"changed\": false, \"path\": \"$path\", \"bits\": $bits }"
diff --git a/tests/ansible.cfg b/tests/ansible.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..3e039f0c9619989b710ca06591dac8eca52ffc20
--- /dev/null
+++ b/tests/ansible.cfg
@@ -0,0 +1,5 @@
+[defaults]
+library = ../
+host_key_checking = False
+roles_path = ./roles/
+callback_whitelist = profile_tasks
diff --git a/tests/collectd.yml b/tests/collectd.yml
new file mode 100644
index 0000000000000000000000000000000000000000..325bdd0b4e5e3790ebf9aa28aa0530eb6f28c8d6
--- /dev/null
+++ b/tests/collectd.yml
@@ -0,0 +1,23 @@
+---
+- name: APT install
+  apt:
+    name: collectd-core
+    state: latest
+    install_recommends: no
+
+- name: Collectd facts
+  collectd_facts:
+  register: collectd_facts
+
+- name: Debug
+  debug:
+    var: collectd_facts
+
+- name: Assertions
+  assert:
+    that:
+    - collectd_facts|changed == False
+    - collectd.major is number
+    - collectd.minor is number
+    - collectd.patch is number
+    - collectd.version is defined
diff --git a/tests/nginx.yml b/tests/nginx.yml
new file mode 100644
index 0000000000000000000000000000000000000000..946bad7b2fa1a48b4f8e5e37a7875f7b68120012
--- /dev/null
+++ b/tests/nginx.yml
@@ -0,0 +1,23 @@
+---
+- name: APT install
+  apt:
+    name: nginx-light
+    state: latest
+    install_recommends: no
+
+- name: Nginx facts
+  nginx_facts:
+  register: nginx_facts
+
+- name: Debug
+  debug:
+    var: nginx_facts
+
+- name: Assertions
+  assert:
+    that:
+    - nginx_facts|changed == False
+    - nginx.major is number
+    - nginx.minor is number
+    - nginx.patch is number
+    - nginx.version is defined
diff --git a/tests/playbook.yaml b/tests/playbook.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..f69f17825f1a02bcc1f47c35130e9da19b96f0e3
--- /dev/null
+++ b/tests/playbook.yaml
@@ -0,0 +1,54 @@
+---
+- hosts: localhost
+  connection: local
+  gather_facts: False
+  become: False
+  tasks:
+      - name: Assertion
+        assert:
+            that:
+                - distro is defined
+                - release is defined
+
+      - name: Remove existing Docker container
+        # Use the command module instead of docker/ docker-container for
+        # consistency across different versions of Ansible.
+        command: 'docker rm -f ansible_modules_{{ distro }}_{{ release }}'
+        register: docker_rm
+        failed_when: "docker_rm|failed and 'no such container' not in docker_rm.stderr|lower"
+        changed_when: "'no such container' not in docker_rm.stderr|lower"
+
+      - name: Create Docker container
+        # Use the command module instead of docker/ docker-container for
+        # consistency across different versions of Ansible.
+        command: 'docker run --detach --name ansible_modules_{{ distro }}_{{ release }} {{ distro }}:{{ release }} tail -f /.dockerenv'
+        changed_when: True
+
+      - name: Add Docker container to inventory
+        add_host:
+            name: 'ansible_modules_{{ distro }}_{{ release }}'
+            ansible_connection: docker
+            ansible_user: root
+            groups: containers
+
+- hosts: containers
+  gather_facts: False
+  roles:
+      - name: debian-bootstrap
+  post_tasks:
+  - name: Include test tasks
+    with_fileglob: '{{ playbook_dir }}/*.yml'
+    include: '{{ item }}'
+
+- hosts: localhost
+  connection: local
+  become: False
+  gather_facts: False
+  tasks:
+      - name: Remove existing Docker container
+        # Use the command module instead of docker/ docker-container for
+        # consistency across different versions of Ansible.
+        command: 'docker rm -f ansible_modules_{{ distro }}_{{ release }}'
+        register: docker_rm
+        failed_when: "docker_rm|failed and 'no such container' not in docker_rm.stderr|lower"
+        changed_when: "'no such container' not in docker_rm.stderr|lower"
diff --git a/tests/requirements.yaml b/tests/requirements.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..e396834c4b61b7991bc92bf783f6b15f3d21d68d
--- /dev/null
+++ b/tests/requirements.yaml
@@ -0,0 +1,3 @@
+---
+- src: adarnimrod.debian-bootstrap
+  name: debian-bootstrap
diff --git a/tests/roles/.gitkeep b/tests/roles/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/ssl.yml b/tests/ssl.yml
new file mode 100644
index 0000000000000000000000000000000000000000..98beeeeac2d08afb9a682bc2af2da24f28ae5d14
--- /dev/null
+++ b/tests/ssl.yml
@@ -0,0 +1,44 @@
+---
+- name: APT install
+  apt:
+    name: openssl
+    state: latest
+    install_recommends: no
+
+- name: DH params for missing file
+  ignore_errors: True
+  dhparams:
+    path: /etc/ssl/dhparams.pem
+  register: missing_dhparams
+
+- name: Debug
+  debug:
+    var: missing_dhparams
+
+- name: Assertions
+  assert:
+    that:
+    - missing_dhparams.bits == 0
+    - missing_dhparams|failed == True
+    - missing_dhparams|changed == False
+
+- name: Generate DH params
+  command: openssl dhparam -out /etc/ssl/dhparams.pem 2048
+  changed_when: True
+
+- name: DH params for existing file
+  dhparams:
+    path: /etc/ssl/dhparams.pem
+  register: existing_dhparams
+
+- name: Debug
+  debug:
+    var: existing_dhparams
+
+- name: Assertions
+  assert:
+    that:
+    - existing_dhparams.bits == 2048
+    - existing_dhparams|failed == False
+    - existing_dhparams|changed == False
+    - existing_dhparams.path == '/etc/ssl/dhparams.pem'
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000000000000000000000000000000000000..65336ca722e278879ed0d5699ce2fdfdf8c65970
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,40 @@
+[tox]
+skip_install = True
+skipsdist = True
+envlist = ansible{2.3.1.0,2.2.3.0,2.1.6.0,2.0.2.0}-image_{ubuntu_xenial,ubuntu_trusty,ubuntu_precise,debian_stretch,debian_jessie,debian_wheezy}, pre-commit
+
+[testenv]
+basepython = python2.7
+deps =
+    ansible2.3.1.0: ansible==2.3.1.0
+    ansible2.2.3.0: ansible==2.2.3.0
+    ansible2.1.6.0: ansible==2.1.6.0
+    ansible2.0.2.0: ansible==2.0.2.0
+    docker-py>=1.7.0
+passenv = TERM HOME VBOX* ANSIBLE_*
+setenv =
+    ANSIBLE_VERBOSITY=2
+changedir = {toxinidir}/tests/
+commands =
+    ansible-galaxy install -r requirements.yaml
+    image_ubuntu_xenial:  ansible-playbook playbook.yaml -e "distro=ubuntu release=xenial"  -i localhost, {posargs}
+    image_ubuntu_trusty:  ansible-playbook playbook.yaml -e "distro=ubuntu release=trusty"  -i localhost, {posargs}
+    image_ubuntu_precise: ansible-playbook playbook.yaml -e "distro=ubuntu release=precise" -i localhost, {posargs}
+    image_debian_stretch: ansible-playbook playbook.yaml -e "distro=debian release=stretch" -i localhost, {posargs}
+    image_debian_jessie:  ansible-playbook playbook.yaml -e "distro=debian release=jessie"  -i localhost, {posargs}
+    image_debian_wheezy:  ansible-playbook playbook.yaml -e "distro=debian release=wheezy"  -i localhost, {posargs}
+
+[testenv:pre-commit]
+deps =
+    pre-commit
+    ansible
+passenv = TERM HOME VBOX* ANSIBLE_*
+setenv =
+    ANSIBLE_ROLES_PATH={toxinidir}/tests/roles
+changedir = {toxinidir}/
+commands =
+    ansible-galaxy install -r tests/requirements.yaml
+    pre-commit run --all-files
+
+[flake8]
+exclude = ldap/ldap_attr.py