diff --git a/.gitignore b/.gitignore
index 73d283f54728d3f913dd73f1653169f8603b98bd..36e1a40a0e9ca088e02560042f57c841ea89ef19 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,4 +18,7 @@ tools/ess-env.conf
 .vscode
 require-ess/require.Makefile
 require-ess/Db/*.db
-require-ess/Db/*.d
\ No newline at end of file
+require-ess/Db/*.d
+cellMods
+*.log
+__pycache__
\ No newline at end of file
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 8bfd42d2ea2a631e741be16f944a020d8bae850b..a54104a0bec649991e26645314caf525b55afd5d 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -38,7 +38,6 @@ shfmt:
 
 build require:
   stage: build
-  when: always
   before_script:
     - curl -L -o epics.tar.gz https://artifactory.esss.lu.se/artifactory/e3/epics-base-$E3_CI_BASE_VERSION.tar.gz
     - tar -zxvf epics.tar.gz
@@ -54,10 +53,13 @@ build require:
   artifacts:
     paths:
       - epics
+      - configure/*.local
 
 test require:
   stage: test
   before_script:
     - source $(pwd)/epics/base-*/require/*/bin/setE3Env.bash
   script:
-    - echo exit | iocsh.bash
\ No newline at end of file
+    - make test
+  needs:
+    - build require
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..bb60d25257a6cfd559bdbfb6f52c1040d9c49dd5
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,19 @@
+repos:
+  - repo: https://github.com/ambv/black
+    rev: 21.6b0
+    hooks:
+      - id: black
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v2.3.0
+    hooks:
+      - id: flake8
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v4.0.1
+    hooks:
+      - id: end-of-file-fixer
+      - id: trailing-whitespace
+  - repo: https://github.com/timothycrosley/isort
+    rev: 5.9.1
+    hooks:
+    - id: isort
+      args: ["--profile", "black"]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a69614fad21acbb7f90fc2175bd51410f0fb491..e6286331288610624503a2bb6a011314011ebfe1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 * Removed `iocsh_gdb.bash` and `iocsh_nice.bash`, both of whose functionality can be called via `iocsh.bash -dg` and `iocsh.bash -n`, respectively.
 * Patch file location has been change from `patch/Site/$VERSION-description.p0.patch` to `patch/Site/$VERSION/description.p0.patch`
 * Require will automatically build `.template` and `.substitutions` files into the common build directory instead of into the source Db path
+* Rudimentary testing has been added:
+* * Tests that the correct version is loaded
+* * Tests that elementary patching/building works as expected
 
 ### Bugfixes
 * `iocsh.bash --help` (and variants) no longer loads tries to load `env.sh`.
@@ -90,7 +93,7 @@ A second major change (mostly via bugfixes) is that the local install command, `
   the old -loc is still supported, but will be deprecated in a future release.
 * Added a `prebuild` target that runs before build so module developers can run specific code before the build process.
 * A module developer can now install dbd files separate from the module dbd file by using `DBD_INSTALLS += file.dbd`.
-  
+
 ### Bugfixes
 * Ensures that lowercase module names are enforced consistently
 * Vendor libraries are only installed at install time, not at build time
diff --git a/configure/CONFIG b/configure/CONFIG
index d747a382e60dadbc5f0dbe3563f3333bcc147dba..9756166a880a341f3e99bcc312e84753026cf688 100644
--- a/configure/CONFIG
+++ b/configure/CONFIG
@@ -21,10 +21,12 @@ endif
 
 include $(EPICS_BASE)/configure/CONFIG_BASE_VERSION
 include $(TOP)/configure/E3/CONFIG_REQUIRE
+include $(TOP)/configure/E3/CONFIG_SHELL
 include $(TOP)/configure/E3/CONFIG_E3_PATH
 include $(TOP)/configure/E3/CONFIG_E3_MAKEFILE
 include $(TOP)/configure/E3/CONFIG_EPICS
 include $(TOP)/configure/E3/CONFIG_EXPORT
+include $(TOP)/configure/E3/CONFIG_TEST
 
 
 VARS_EXCLUDES+=EPICS_VERSION
diff --git a/configure/E3/CONFIG_SHELL b/configure/E3/CONFIG_SHELL
new file mode 100644
index 0000000000000000000000000000000000000000..22770a9bd631115762d404101ec6d8a1390a9909
--- /dev/null
+++ b/configure/E3/CONFIG_SHELL
@@ -0,0 +1,6 @@
+
+ifdef DEBUG_SHELL
+  SHELL = /bin/sh -x
+else
+  SHELL = /usr/bin/bash
+endif
diff --git a/configure/E3/CONFIG_TEST b/configure/E3/CONFIG_TEST
new file mode 100644
index 0000000000000000000000000000000000000000..4b27c89ce95016ffa5bc114f390b55f8ebaf68db
--- /dev/null
+++ b/configure/E3/CONFIG_TEST
@@ -0,0 +1 @@
+TEST_DIR:=$(TOP)/tests
diff --git a/configure/E3/RULES_TEST b/configure/E3/RULES_TEST
new file mode 100644
index 0000000000000000000000000000000000000000..5a6e2b8473f663b14bfa544ef6867e3f46f1b39e
--- /dev/null
+++ b/configure/E3/RULES_TEST
@@ -0,0 +1,4 @@
+.PHONY: test
+
+test:
+	pytest $(TEST_DIR) -v
diff --git a/configure/RULES b/configure/RULES
index 02026e3d3d129598e499127cd7faa84db928adc1..49e2bba9ac5185131d91e86a39aee2e2fe70f329 100644
--- a/configure/RULES
+++ b/configure/RULES
@@ -15,6 +15,7 @@ include $(TOP)/configure/E3/RULES_REQUIRE
 include $(TOP)/configure/E3/RULES_PATCH
 include $(TOP)/configure/E3/RULES_DB
 include $(TOP)/configure/E3/RULES_VARS
+include $(TOP)/configure/E3/RULES_TEST
 
 
 ifneq (,$(findstring dev,$(MAKECMDGOALS)))
diff --git a/require-ess/tools/driver.makefile b/require-ess/tools/driver.makefile
index 7ee92663acd4f0592f49b87922942982d830ed12..bf7574edc55fcdfa6e6d7de8b5d6178367dcffbc 100644
--- a/require-ess/tools/driver.makefile
+++ b/require-ess/tools/driver.makefile
@@ -44,7 +44,7 @@
 #    Name of the built module.
 #    If not defined, it is derived from the directory name.
 # SOURCES
-#    All source files to compile. 
+#    All source files to compile.
 #    If not defined, default is all *.c *.cc *.cpp *.st *.stt in
 #    the source directory (where you run make).
 #    If you define this, you must list ALL sources.
@@ -66,7 +66,7 @@ USERMAKEFILE:=$(lastword $(filter-out $(lastword ${MAKEFILE_LIST}), ${MAKEFILE_L
 
 
 ##---## In E3, We only use ONE EPICS_BASE in order to COMPILE A MODULE
-##---## 
+##---##
 ##---## In E3,  EPICS_LOCATION is the EPICS BASE  /testing/epics/base-MAJ.MIN.REV[.PATCH]
 EPICS_LOCATION =
 ##---## In E3, we extract BASE_VERSION from EPICS_LOCATION
@@ -74,10 +74,10 @@ E3_EPICS_VERSION:=$(patsubst base-%,%,$(notdir $(EPICS_LOCATION)))
 E3_SITEMODS_PATH =
 E3_SITEAPPS_PATH =
 BUILD_EPICS_VERSIONS = $(E3_EPICS_VERSION)
-##---## 
+##---##
 
 BUILDCLASSES = Linux
-EPICS_MODULES = 
+EPICS_MODULES =
 
 MODULE_LOCATION =${EPICS_MODULES}/$(or ${PRJ},$(error PRJ not defined))/$(or ${LIBVERSION},$(error LIBVERSION not defined))
 
@@ -276,14 +276,14 @@ endef
 $(eval $(call INSTALL_UI_RULE,QT,${CONFIGBASE}/qt,qt/*))
 
 else # EPICSVERSION
-# EPICSVERSION defined 
+# EPICSVERSION defined
 # Second or third run (see T_A branch below)
 
 EPICS_BASE=${EPICS_LOCATION}
 
 CONFIG=${EPICS_BASE}/configure
 
-# There is no 64 bit support before 3.14.12 
+# There is no 64 bit support before 3.14.12
 ifneq ($(filter %_64,$(EPICS_HOST_ARCH)),)
 ifeq ($(wildcard $(EPICS_BASE)/lib/$(EPICS_HOST_ARCH)),)
 EPICS_HOST_ARCH:=$(patsubst %_64,%,$(EPICS_HOST_ARCH))
@@ -405,7 +405,7 @@ uninstall::
 
 debug::
 	@echo "EPICS_BASE = ${EPICS_BASE}"
-	@echo "EPICSVERSION = ${EPICSVERSION}" 
+	@echo "EPICSVERSION = ${EPICSVERSION}"
 	@echo "CROSS_COMPILER_TARGET_ARCHS = ${CROSS_COMPILER_TARGET_ARCHS}"
 	@echo "EXCLUDE_ARCHS = ${EXCLUDE_ARCHS}"
 	@echo "LIBVERSION = ${LIBVERSION}"
@@ -480,13 +480,13 @@ export VLIBS
 
 export CFG
 
-# These variables are written into a .yaml file in the installed module directory to keep track of 
+# These variables are written into a .yaml file in the installed module directory to keep track of
 # metadata for which module was compiled.
 
-${PRJ}_GIT_DESC := $(shell git describe --tags 2> /dev/null || git rev-parse HEAD)
+${PRJ}_GIT_DESC := $(shell git describe --tags 2> /dev/null || git rev-parse HEAD 2> /dev/null)
 export ${PRJ}_GIT_DESC
 # The formatting here is just to make sure this is properly parseable .yaml data
-${PRJ}_GIT_STATUS := [ $(shell git status --porcelain | grep -v "\.Makefile" | sed 's/^/\\\"/' | sed 's/$$/\\\", /')]
+${PRJ}_GIT_STATUS := [ $(shell git status --porcelain 2> /dev/null | grep -v "\.Makefile" | sed 's/^/\\\"/' | sed 's/$$/\\\", /')]
 export ${PRJ}_GIT_STATUS
 
 else # in O.*
@@ -507,7 +507,7 @@ EPICS_INCLUDES =
 # Add include directory of foreign modules to include file search path.
 #
 # The default behaviour is to start with <module>_VERSION and to select the highest
-# available build number, unless a build no. is specified. This is determined with the 
+# available build number, unless a build no. is specified. This is determined with the
 # shell script build_number.sh included with require.
 
 define ADD_INCLUDES_TEMPLATE
@@ -571,7 +571,7 @@ LDFLAGS += ${PROVIDES} ${USR_LDFLAGS_${T_A}}
 # 3.14.8 uses HDEPENDS to select depends mode
 # 3.14.12 uses 'HDEPENDSCFLAGS -MMD' (does not catch #include <...>)
 # 3.15 uses 'HDEPENDS_COMPFLAGS = -MM -MF $@' (does not catch #include <...>)
-HDEPENDS = 
+HDEPENDS =
 HDEPENDS_METHOD = COMP
 HDEPENDS_COMPFLAGS = -c
 MKMF = DO_NOT_USE_MKMF
@@ -630,9 +630,9 @@ debug::
 	@echo "BPTS = ${BPTS}"
 	@echo "DBDINSTALLS = ${DBDINSTALLS}"
 	@echo "HDRS = ${HDRS}"
-	@echo "SOURCES = ${SOURCES}" 
-	@echo "SOURCES_${OS_CLASS} = ${SOURCES_${OS_CLASS}}" 
-	@echo "SRCS = ${SRCS}" 
+	@echo "SOURCES = ${SOURCES}"
+	@echo "SOURCES_${OS_CLASS} = ${SOURCES_${OS_CLASS}}"
+	@echo "SRCS = ${SRCS}"
 	@echo "REQ = ${REQ}"
 	@echo "LIBOBJS = ${LIBOBJS}"
 	@echo "DBDS = ${DBDS}"
@@ -655,12 +655,12 @@ INSTALL_LOADABLE_SHRLIBS=
 # We ony want to include ${BASERULES} from EPICS base if we are /not/ in debug
 # mode. Including this causes all of the source files to be compiled!
 ifeq (,$(findstring debug,${MAKECMDGOALS}))
-include ${BASERULES} 
+include ${BASERULES}
 endif
 
 # Fix incompatible release rules.
 RELEASE_DBDFLAGS = -I ${EPICS_BASE}/dbd
-RELEASE_INCLUDES = -I${EPICS_BASE}/include 
+RELEASE_INCLUDES = -I${EPICS_BASE}/include
 # For EPICS 3.15:
 RELEASE_INCLUDES += -I${EPICS_BASE}/include/compiler/${CMPLR_CLASS}
 RELEASE_INCLUDES += -I${EPICS_BASE}/include/os/${OS_CLASS}
@@ -798,7 +798,7 @@ SNCFLAGS += -r
 # 2) We also need -c option in $(COMPILE.c) in order to compile generated source file properly
 # 3) SNC (2.1.21) should use -o, because without them, snc returns $(*F).i.c instead of $(*F).c
 #    With the EPICS standard building rule, -o and mv are used.
-# 
+#
 # Tuesday, November 28 15:59:37 CET 2017, Jeong Han Lee
 
 
@@ -817,7 +817,7 @@ SNCFLAGS += -r
 	@mv $(*F).c.tmp $(*F).c
 	@echo ">> Compiling $(*F).c"
 	$(RM) $@
-	$(COMPILE.c) -c ${SNC_CFLAGS} $(*F).c 
+	$(COMPILE.c) -c ${SNC_CFLAGS} $(*F).c
 	@echo ">> Building $(*F)_snl.dbd"
 	awk -F [\(\)]  '/epicsExportRegistrar/ { print "registrar (" $$2 ")"}' $(*F).c > $(*F)_snl.dbd
 
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..98827e1dfc7f8fb11c219940d3b2332dd7848e1b
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,48 @@
+from pathlib import Path
+
+import pytest
+from git import Repo
+
+from .utils import TEST_MODULE_NAME
+
+
+@pytest.fixture
+def wrapper(tmpdir, request):
+    """
+    Sets up a wrapper with the minimal necessary configuration in order to build a module
+
+    Note that a number of necessary variables are expected to be present in the environment
+    """
+    wrapper_dir = Path(tmpdir / "wrapper")
+    test_dir = wrapper_dir / TEST_MODULE_NAME
+    test_dir.mkdir(parents=True)
+
+    config_file = """
+TOP:=$(CURDIR)
+
+include $(REQUIRE_CONFIG)/CONFIG
+include $(REQUIRE_CONFIG)/RULES_SITEMODS
+"""
+
+    module_makefile = """
+where_am_I := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+include $(E3_REQUIRE_TOOLS)/driver.makefile
+
+TEMPLATES += {templates}
+SOURCES += {sources}
+DBDS += {dbds}
+HEADERS += {headers}
+"""
+
+    # TODO: This should not be necessary, but it is for patching.
+    Repo.init(wrapper_dir)
+    with open(wrapper_dir / "Makefile", "w") as f:
+        f.write(config_file)
+
+    make_vars = {"templates": "", "sources": "", "dbds": "", "headers": ""}
+    make_vars.update(**request.param)
+    with open(wrapper_dir / f"{TEST_MODULE_NAME}.Makefile", "w") as f:
+        f.write(module_makefile.format(**make_vars))
+    with open(wrapper_dir / TEST_MODULE_NAME / "test.dbd", "w") as f:
+        pass
+    yield wrapper_dir
diff --git a/tests/test_build.py b/tests/test_build.py
new file mode 100644
index 0000000000000000000000000000000000000000..cab6b2dfece42af1f79c3caa2551716d970fb8ed
--- /dev/null
+++ b/tests/test_build.py
@@ -0,0 +1,104 @@
+import re
+
+import pytest
+
+from .utils import TEST_MODULE_NAME, run_make
+
+MODULE_VERSION = "0.0.0+0"
+MODULE_VERSION_NO_BUILD = "0.0.0"
+
+RE_MISSING_FILE = "No rule to make target [`']{filename}'"
+
+DB_FILE = """record(ai, "TEST") {
+
+}
+"""
+
+PATCH_FILE = """
+diff --git database.db database.db
+index 1806ff6..8701832 100644
+--- database.db
++++ database.db
+@@ -1,3 +1,3 @@
+ record(ai, "TEST") {{
+-
++    field(DESC, "{desc}")
+ }}
+"""
+
+
+def create_patch_file(path, desc):
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with open(path, "w") as f:
+        f.write(PATCH_FILE.format(desc=desc))
+
+
+@pytest.mark.parametrize(
+    "wrapper",
+    [{"templates": "database.db"}],
+    indirect=True,
+)
+def test_patch(wrapper):
+    db_path = wrapper / TEST_MODULE_NAME / "database.db"
+    with open(db_path, "w") as f:
+        f.write(DB_FILE)
+
+    patch_dir = wrapper / "patch" / "Site"
+    create_patch_file(patch_dir / MODULE_VERSION / "apply.p0.patch", "OK")
+    create_patch_file(
+        patch_dir / MODULE_VERSION_NO_BUILD / "dont-apply.p0.patch", "Bad"
+    )
+    create_patch_file(patch_dir / (MODULE_VERSION + "-dont-apply.p0.patch"), "Bad")
+
+    rc, outs, _ = run_make(
+        wrapper,
+        "init",
+        E3_MODULE_VERSION=MODULE_VERSION,
+    )
+    assert rc == 0
+    assert "You are in the local source mode" in outs.decode("utf-8")
+
+    rc, _, _ = run_make(wrapper, "patch", E3_MODULE_VERSION=MODULE_VERSION)
+    assert rc == 0
+    with open(db_path, "r") as f:
+        db_contents = f.read()
+    assert 'field(DESC, "OK")' in db_contents
+    assert "Bad" not in db_contents
+
+    rc, _, _ = run_make(wrapper, "build", E3_MODULE_VERSION=MODULE_VERSION)
+    assert rc == 0
+    assert any((wrapper / TEST_MODULE_NAME).glob("O.*"))
+
+    rc, _, _ = run_make(wrapper, "cellinstall", E3_MODULE_VERSION=MODULE_VERSION)
+    assert rc == 0
+    assert any((wrapper / "cellMods").glob("**/*.db"))
+
+
+@pytest.mark.parametrize(
+    "wrapper",
+    [{"dbds": "nonexistent.dbd"}],
+    indirect=True,
+)
+def test_missing_dbd_file(wrapper):
+    rc, _, errs = run_make(wrapper, "build")
+
+    assert rc == 2
+    assert re.search(
+        RE_MISSING_FILE.format(filename=re.escape("../nonexistent.dbd")),
+        errs.decode("utf-8"),
+    )
+
+
+@pytest.mark.parametrize(
+    "wrapper",
+    [{"sources": "nonexistent.c"}],
+    indirect=True,
+)
+def test_missing_source_file(wrapper):
+    rc, _, errs = run_make(wrapper, "build")
+
+    assert rc == 2
+    assert re.search(
+        RE_MISSING_FILE.format(filename=re.escape("nonexistent.o")),
+        errs.decode("utf-8"),
+    )
diff --git a/tests/test_versions.py b/tests/test_versions.py
new file mode 100644
index 0000000000000000000000000000000000000000..e2285d276ac2bc718719ee0e1ca4e4babadc194a
--- /dev/null
+++ b/tests/test_versions.py
@@ -0,0 +1,61 @@
+import re
+
+import pytest
+
+from .utils import TEST_MODULE_NAME, run_ioc_get_output, run_make
+
+RE_MODULE_LOADED = f"Loaded {TEST_MODULE_NAME} version {{version}}"
+RE_MODULE_NOT_LOADED = f"Module {TEST_MODULE_NAME} (not available|version {{required}} not available|version {{required}} not available \\(but other versions are available\\))"
+
+
+@pytest.mark.parametrize(
+    "wrapper",
+    [{"templates": "", "sources": "", "dbds": "test.dbd", "headers": ""}],
+    indirect=True,
+)
+@pytest.mark.parametrize(
+    "requested, expected, installed",
+    [
+        # If nothing is installed, nothing should be loaded
+        ("", "", []),
+        # Test versions can be loaded
+        ("test", "test", ["test", "0.0.1"]),
+        # Numeric versions should be prioritized over test versions
+        ("", "0.0.1+0", ["test", "0.0.1"]),
+        ("", "0.0.1+0", ["0.0.1", "test"]),
+        # Highest build number should be loaded if version is unspecified
+        ("", "0.0.1+7", ["0.0.1", "0.0.1+7", "0.0.1+3"]),
+        # Only load the given build number if it is specified
+        ("0.0.1+0", "", ["0.0.1+1"]),
+        # If no build number is specified, load the highest build number
+        ("0.0.1", "0.0.1+4", ["0.1.0", "0.0.1+4", "0.0.1"]),
+        # Build number 0 means load that version exactly
+        ("0.0.1+0", "0.0.1+0", ["0.0.1+0"]),
+        ("0.0.1+0", "0.0.1+0", ["0.0.1", "0.0.1+1", "1.0.0"]),
+        # 1-test counts as a test version, as does 1.0
+        ("", "0.0.1+0", ["0.0.1", "1-test"]),
+        ("", "0.0.1+0", ["0.0.1", "1.0"]),
+        # Numeric version should be prioritised over "higher" test version
+        ("", "0.1.0+0", ["0.1.0", "1.0.0-rc1"]),
+    ],
+)
+def test_version(wrapper, requested, expected, installed):
+    for version in installed:
+        returncode, _, _ = run_make(
+            wrapper,
+            "clean",
+            "cellinstall",
+            E3_MODULE_VERSION=version,
+        )
+        assert returncode == 0
+
+    rc, o, e = run_ioc_get_output(requested, wrapper / "cellMods")
+
+    if expected:
+        match = re.search(RE_MODULE_LOADED.format(version=re.escape(expected)), o)
+        assert rc == 0
+        assert match
+    else:
+        match = re.search(RE_MODULE_NOT_LOADED.format(required=re.escape(requested)), o)
+        assert match
+        assert rc != 0
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..14b691713d17df414ddb7051f85d8bf8475dbb43
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,64 @@
+import os
+import subprocess
+import time
+from pathlib import Path
+from random import choice
+from string import ascii_lowercase
+
+from run_iocsh import IOC
+
+# Random module name
+TEST_MODULE_NAME = "test_mod_" + "".join(choice(ascii_lowercase) for _ in range(16))
+
+EPICS_BASE = os.environ.get("EPICS_BASE")
+E3_REQUIRE_VERSION = os.environ.get("E3_REQUIRE_VERSION")
+
+
+def set_env_vars(env: dict) -> dict:
+    """
+    Set a number of environment variables needed for require to build a module
+    """
+    assert "EPICS_BASE" in env
+    assert Path(env["EPICS_BASE"]).is_dir
+    assert "E3_REQUIRE_VERSION" in env
+    assert env["E3_REQUIRE_VERSION"]
+
+    E3_REQUIRE_LOCATION = f"{EPICS_BASE}/require/{E3_REQUIRE_VERSION}"
+    assert Path(E3_REQUIRE_LOCATION).is_dir()
+    env["E3_REQUIRE_LOCATION"] = E3_REQUIRE_LOCATION
+
+    REQUIRE_CONFIG = f"{E3_REQUIRE_LOCATION}/configure"
+    env["REQUIRE_CONFIG"] = REQUIRE_CONFIG
+
+    env["E3_MODULE_NAME"] = TEST_MODULE_NAME
+    env["E3_MODULE_SRC_PATH"] = TEST_MODULE_NAME
+    env["E3_MODULE_MAKEFILE"] = f"{TEST_MODULE_NAME}.Makefile"
+
+    # Add a default version
+    env["E3_MODULE_VERSION"] = "0.0.0"
+
+    return env
+
+
+def run_make(path, *args, **kwargs):
+    """
+    Attempt to run `make <args>` in the specified path with <kwargs> as environment variables
+    """
+    test_env = set_env_vars(os.environ.copy())
+    for kw in kwargs:
+        test_env[kw] = kwargs[kw]
+    make_cmd = ["make", "-C", path] + list(args)
+    p = subprocess.Popen(
+        make_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=test_env
+    )
+    outs, errs = p.communicate()
+    return p.returncode, outs, errs
+
+
+def run_ioc_get_output(version, cell_path):
+    """
+    Run an IOC and try to load the test module
+    """
+    with IOC("-r", f"{TEST_MODULE_NAME},{version}", "-l", cell_path) as ioc:
+        time.sleep(1)
+    return ioc.proc.returncode, ioc.outs, ioc.errs