diff --git a/.gitlab/README.md.template b/.gitlab/README.md.template
index 1f2172898790ba0fb387eaa83bc28efea76d2257..f231b2bb4fd7f73cfee1eb4a11d1f432751ee0ad 100644
--- a/.gitlab/README.md.template
+++ b/.gitlab/README.md.template
@@ -37,7 +37,7 @@ include:
       version: <VERSION>
       opentofu_version: <OPENTOFU_VERSION>
 
-stages: [validate, build, deploy, cleanup]
+stages: [validate, test, build, deploy, cleanup]
 
 ---
 
@@ -67,7 +67,7 @@ include:
       version: 0.10.0
       opentofu_version: 1.6.1
 
-stages: [validate, build, deploy, cleanup]
+stages: [validate, test, build, deploy, cleanup]
 
 ---
 
@@ -82,7 +82,7 @@ include:
       version: latest
       opentofu_version: 1.6.1
 
-stages: [validate, build, deploy, cleanup]
+stages: [validate, test, build, deploy, cleanup]
 ```
 
 Or import all jobs as hidden templates ready to be extended:
@@ -161,6 +161,7 @@ The following job components exist:
 
 - [`fmt`](templates/fmt.yml)
 - [`validate`](templates/validate.yml)
+- [`test`](templates/test.yml)
 - [`plan`](templates/plan.yml)
 - [`apply`](templates/apply.yml)
 - [`destroy`](templates/destroy.yml)
diff --git a/README.md b/README.md
index 9f538bd033d3d6e5dba7409ca6a356c40ea4c840..650fbd1048f685f2a08abe3377335804fe889997 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ include:
       version: <VERSION>
       opentofu_version: <OPENTOFU_VERSION>
 
-stages: [validate, build, deploy, cleanup]
+stages: [validate, test, build, deploy, cleanup]
 
 ---
 
@@ -69,7 +69,7 @@ include:
       version: 0.10.0
       opentofu_version: 1.6.1
 
-stages: [validate, build, deploy, cleanup]
+stages: [validate, test, build, deploy, cleanup]
 
 ---
 
@@ -84,7 +84,7 @@ include:
       version: latest
       opentofu_version: 1.6.1
 
-stages: [validate, build, deploy, cleanup]
+stages: [validate, test, build, deploy, cleanup]
 ```
 
 Or import all jobs as hidden templates ready to be extended:
@@ -163,6 +163,7 @@ The following job components exist:
 
 - [`fmt`](templates/fmt.yml)
 - [`validate`](templates/validate.yml)
+- [`test`](templates/test.yml)
 - [`plan`](templates/plan.yml)
 - [`apply`](templates/apply.yml)
 - [`destroy`](templates/destroy.yml)
@@ -176,6 +177,7 @@ Have a look at the individual template spec to learn about the available inputs.
 | Name | Default | Description |
 | ---- | ------- | ----------- |
 | `stage_validate` | `validate` | Defines the validate stage. This stage includes the `fmt` and `validate` jobs. |
+| `stage_test` | `test` | Defines the test stage. This stage includes the `test` job. |
 | `stage_build` | `build` | Defines the build stage. This stage includes the `plan` job. |
 | `stage_deploy` | `deploy` | Defines the deploy stage. This stage includes the `apply` job. |
 | `stage_cleanup` | `cleanup` | Defines the cleanup stage. This stage includes the `destroy` and `delete-state` jobs. |
diff --git a/src/gitlab-tofu.sh b/src/gitlab-tofu.sh
index 7f3307396a5cfa82455d33b83863c611a31aa418..aa62e139e4c3d6a34ccedc075806ff2a8bad302d 100644
--- a/src/gitlab-tofu.sh
+++ b/src/gitlab-tofu.sh
@@ -172,6 +172,10 @@ if [ $sourced -eq 0 ]; then
       $TF_IMPLICIT_INIT && terraform_init -backend=false
       tofu "${TF_CHDIR_OPT}" "${@}"
     ;;
+    "test")
+      $TF_IMPLICIT_INIT && terraform_init -backend=false
+      tofu "${TF_CHDIR_OPT}" "${@}"
+    ;;
     --)
       shift
       tofu "${TF_CHDIR_OPT}" "${@}"
diff --git a/templates/full-pipeline.yml b/templates/full-pipeline.yml
index a2ae99f9e12043c5c2cce5504fdd1b3b694db1cb..38a1be142cbddb5376657597f291c08b5602f87e 100644
--- a/templates/full-pipeline.yml
+++ b/templates/full-pipeline.yml
@@ -4,6 +4,9 @@ spec:
     stage_validate:
       default: 'validate'
       description: 'Defines the validate stage. This stage includes the `fmt` and `validate` jobs.'  
+    stage_test:
+      default: 'test'
+      description: 'Defines the test stage. This stage includes the `test` job.'
     stage_build:
       default: 'build'
       description: 'Defines the build stage. This stage includes the `plan` job.'
@@ -82,6 +85,19 @@ include:
       image_name: $[[ inputs.image_name ]]
       root_dir: $[[ inputs.root_dir ]]
       state_name: $[[ inputs.state_name ]]
+  - local: '/templates/test.yml'
+    inputs:
+      as: 'test'
+      stage: $[[ inputs.stage_test ]]
+      version: $[[ inputs.version ]]
+      opentofu_version: $[[ inputs.opentofu_version ]]
+      image_registry_base: $[[ inputs.image_registry_base ]]
+      image_name: $[[ inputs.image_name ]]
+      root_dir: $[[ inputs.root_dir ]]
+      state_name: $[[ inputs.state_name ]]
+    rules:
+      - exists: 
+          - $[[ inputs.root_dir ]]/**/*.tftest.hcl
   - local: '/templates/plan.yml'
     inputs:
       as: 'plan'
diff --git a/templates/job-templates.yml b/templates/job-templates.yml
index 7a77ef50ae7c0e9828f7e756dfc4da68f61a44ce..f8f8aa1199c2373131d41bbb2afd04e4c732abbe 100644
--- a/templates/job-templates.yml
+++ b/templates/job-templates.yml
@@ -4,6 +4,9 @@ spec:
     stage_validate:
       default: 'validate'
       description: 'Defines the validate stage. This stage includes the `fmt` and `validate` jobs.'  
+    stage_test:
+      default: 'test'
+      description: 'Defines the test stage. This stage includes the `test` job.'
     stage_build:
       default: 'build'
       description: 'Defines the build stage. This stage includes the `plan` job.'
@@ -85,6 +88,16 @@ include:
       image_name: $[[ inputs.image_name ]]
       root_dir: $[[ inputs.root_dir ]]
       state_name: $[[ inputs.state_name ]]
+  - local: '/templates/test.yml'
+    inputs:
+      as: '$[[ inputs.job_name_prefix ]]test'
+      stage: $[[ inputs.stage_test ]]
+      version: $[[ inputs.version ]]
+      opentofu_version: $[[ inputs.opentofu_version ]]
+      image_registry_base: $[[ inputs.image_registry_base ]]
+      image_name: $[[ inputs.image_name ]]
+      root_dir: $[[ inputs.root_dir ]]
+      state_name: $[[ inputs.state_name ]]
   - local: '/templates/plan.yml'
     inputs:
       as: '$[[ inputs.job_name_prefix ]]plan'
diff --git a/templates/test.yml b/templates/test.yml
new file mode 100644
index 0000000000000000000000000000000000000000..be0cbe8cf0ae994246bcd4da7b211b9a7c352db6
--- /dev/null
+++ b/templates/test.yml
@@ -0,0 +1,73 @@
+spec:
+  inputs:
+    # Job and Stage name
+    as:
+      default: 'test'
+      description: 'Defines the name of this job.'
+    stage:
+      default: 'test'
+      description: 'Defines the stage that this job will belong to.'
+
+    # Versions
+    # This version is only required, because we cannot access the context of the component,
+    # see https://gitlab.com/gitlab-org/gitlab/-/issues/438275
+    version:
+      default: 'latest'
+      description: 'Version of this component. Has to be the same as the one in the component include entry.'
+
+    opentofu_version:
+      default: '1.6.2'
+      options:
+        - '$OPENTOFU_VERSION'
+        - '1.6.2'
+        - '1.6.1'
+        - '1.6.0'
+      description: 'OpenTofu version that should be used.'
+
+    # Images
+    image_registry_base:
+      default: '$CI_REGISTRY/components/opentofu'
+      description: 'Host URI to the job images. Will be combined with `image_name` to construct the actual image URI.'
+    # FIXME: not yet possible because of https://gitlab.com/gitlab-org/gitlab/-/issues/438722
+    # gitlab_opentofu_image:
+    #   # FIXME: This should reference the component tag that is used.
+    #   #        Currently, blocked by https://gitlab.com/gitlab-org/gitlab/-/issues/438275
+    #   # default: '$CI_REGISTRY/components/opentofu/gitlab-opentofu:$[[ inputs.opentofu_version ]]'
+    #   default: '$CI_REGISTRY/components/opentofu/gitlab-opentofu:$[[ inputs.version ]]-opentofu$[[ inputs.opentofu_version ]]'
+    #   description: 'Tag of the gitlab-opentofu image.'
+
+    image_name:
+      default: 'gitlab-opentofu'
+      description: 'Image name for the job images. Hosted under `image_registry_base`.'
+
+    # Configuration
+    root_dir:
+      default: ${CI_PROJECT_DIR}
+      description: 'Root directory for the OpenTofu project.'
+    state_name:
+      default: default
+      description: 'Remote OpenTofu state name.'
+
+---
+
+'$[[ inputs.as ]]':
+  stage: $[[ inputs.stage ]]
+  needs: []
+  rules:
+    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
+    - if: $CI_OPEN_MERGE_REQUESTS  # Don't add it to a *branch* pipeline if it's already in a merge request pipeline.
+      when: never
+    - if: $CI_COMMIT_BRANCH        # If there's no open merge request, add it to a *branch* pipeline instead.
+  cache:
+    key: "$__CACHE_KEY_HACK"
+    paths:
+      - $TF_ROOT/.terraform/
+  variables:
+    # FIXME: work around to make slashes work in `cache:key`. see https://gitlab.com/gitlab-org/gitlab/-/issues/439898
+    __CACHE_KEY_HACK: "$[[ inputs.root_dir ]]"
+    TF_ROOT: $[[ inputs.root_dir ]]
+    TF_STATE_NAME: $[[ inputs.state_name ]]
+  image:
+    name: '$[[ inputs.image_registry_base ]]/$[[ inputs.image_name ]]:$[[ inputs.version ]]-opentofu$[[ inputs.opentofu_version ]]'
+  script:
+    - gitlab-tofu test
diff --git a/tests/iac/modules/random-pet/main.tf b/tests/iac/modules/random-pet/main.tf
index ad50e07e95d3bf9f0c9ccb0e1c942583c8d6f358..382c4e673853109279a8d53323b2aad80a8848e2 100644
--- a/tests/iac/modules/random-pet/main.tf
+++ b/tests/iac/modules/random-pet/main.tf
@@ -7,8 +7,6 @@ terraform {
   }
 }
 
-provider "random" {}
-
 resource "random_pet" "random_pet" {
   length = var.length
 }
diff --git a/tests/iac/tests/main.tftest.hcl b/tests/iac/tests/main.tftest.hcl
new file mode 100644
index 0000000000000000000000000000000000000000..fedf96e4c458eee084740b05980a52ffe64362a1
--- /dev/null
+++ b/tests/iac/tests/main.tftest.hcl
@@ -0,0 +1,6 @@
+run "test" {
+  assert {
+    condition     = file(local_file.foo.filename) == "foo!"
+    error_message = "Incorrect content in ${local_file.foo.filename}"
+  }
+}
diff --git a/tests/integration-tests/Defaults.gitlab-ci.yml b/tests/integration-tests/Defaults.gitlab-ci.yml
index 4e856f3a4093ed6ce4f91a7aa24b8f32b882cd0c..0b85e6eb539de02fc8875b3cda43f257c20db60b 100644
--- a/tests/integration-tests/Defaults.gitlab-ci.yml
+++ b/tests/integration-tests/Defaults.gitlab-ci.yml
@@ -17,6 +17,9 @@ fmt:
 validate:
   rules: [{when: always}]
 
+test:
+  rules: [{when: always}]
+
 plan:
   rules: [{when: always}]
 
diff --git a/tests/integration-tests/JobTemplates.gitlab-ci.yml b/tests/integration-tests/JobTemplates.gitlab-ci.yml
index e552295efc4d66997fb478d65b816838452dd4b3..7d2b47b6961409e050c2b0d3c764187bcd700ad9 100644
--- a/tests/integration-tests/JobTemplates.gitlab-ci.yml
+++ b/tests/integration-tests/JobTemplates.gitlab-ci.yml
@@ -7,7 +7,7 @@ include:
       root_dir: $TEST_TF_ROOT
       state_name: $TEST_TF_STATE_NAME
 
-stages: [validate, test, build, deploy, cleanup]
+stages: [validate, build, deploy, cleanup]
 
 # Required to run everything immediately, instead of manually.
 
diff --git a/tests/integration-tests/TestJob.gitlab-ci.yml b/tests/integration-tests/TestJob.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..05b8144592a52fb379795bfbd86ae57aa0d93080
--- /dev/null
+++ b/tests/integration-tests/TestJob.gitlab-ci.yml
@@ -0,0 +1,15 @@
+include:
+  - component: gitlab.com/$CI_PROJECT_PATH/test@$CI_COMMIT_SHA
+    inputs:
+      image_registry_base: $GITLAB_OPENTOFU_IMAGE_BASE
+      version: $CI_COMMIT_SHA
+      opentofu_version: $OPENTOFU_VERSION
+      root_dir: $TEST_TF_ROOT
+      state_name: $TEST_TF_STATE_NAME
+
+stages: [test]
+
+# Required to run everything immediately, instead of manually.
+
+test:
+  rules: [{when: always}]
diff --git a/tests/integration.gitlab-ci.yml b/tests/integration.gitlab-ci.yml
index e72224df461f3338c4f725fe4820bff039b37277..2c71401d87f48a9a6ea7f953bc7194b75c5662e9 100644
--- a/tests/integration.gitlab-ci.yml
+++ b/tests/integration.gitlab-ci.yml
@@ -11,6 +11,7 @@ component:
     matrix:
       - PIPELINE_NAME: [Defaults]
       - PIPELINE_NAME: [JobTemplates]
+      - PIPELINE_NAME: [TestJob]
 
 backport-templates:
   stage: test-integration