[PATCH 07/14] kconfig: test: add framework for Kconfig unit-tests

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

 



I admit various parts in Kconfig are cryptic and need refactoring,
but at the same time, I fear regressions.

There are several subtle corner cases where it is difficult to notice
breakage.  It is time to add unit-tests.

Here is a simple framework based on pytest.  The conftest.py provides
a fixture useful to run commands such as 'oldaskconfig' etc. and
to compare the resulted .config, stdout, stderr with expectations.

How to add test cases?
----------------------

For each test case, you should create a subdirectory under
scripts/kconfig/tests/ (so test cases are seperated from each other).
Every test case directory must contain the following files:

 - __init__.py: describes test functions
 - Kconfig: the top level Kconfig file for this test

To do a useful job, test cases generally need additional data like
input .config and information about expected results.

How to run tests?
-----------------

You need python3 and pytest.  Then, run "make testconfig".
O= option is supported.  If V=1 is given, details logs during tests
are displayed.

Signed-off-by: Masahiro Yamada <yamada.masahiro@xxxxxxxxxxxxx>
---

 scripts/kconfig/Makefile          |   8 ++
 scripts/kconfig/tests/conftest.py | 255 ++++++++++++++++++++++++++++++++++++++
 scripts/kconfig/tests/pytest.ini  |   6 +
 3 files changed, 269 insertions(+)
 create mode 100644 scripts/kconfig/tests/conftest.py
 create mode 100644 scripts/kconfig/tests/pytest.ini

diff --git a/scripts/kconfig/Makefile b/scripts/kconfig/Makefile
index cb3ec53..c5d1d1a 100644
--- a/scripts/kconfig/Makefile
+++ b/scripts/kconfig/Makefile
@@ -135,6 +135,14 @@ PHONY += tinyconfig
 tinyconfig:
 	$(Q)$(MAKE) -f $(srctree)/Makefile allnoconfig tiny.config
 
+# CHECK: -o cache_dir=<path> working?
+PHONY += testconfig
+testconfig: $(obj)/conf
+	$(PYTHON3) -B -m pytest $(srctree)/$(src)/tests \
+	-o cache_dir=$(abspath $(obj)/tests/.cache) \
+	$(if $(findstring 1,$(KBUILD_VERBOSE)),--capture=no)
+clean-dirs += tests/.cache
+
 # Help text used by make help
 help:
 	@echo  '  config	  - Update current config utilising a line-oriented program'
diff --git a/scripts/kconfig/tests/conftest.py b/scripts/kconfig/tests/conftest.py
new file mode 100644
index 0000000..f0f3237
--- /dev/null
+++ b/scripts/kconfig/tests/conftest.py
@@ -0,0 +1,255 @@
+# SPDX-License-Identifier: GPL-2.0
+#
+# Copyright (C) 2018 Masahiro Yamada <yamada.masahiro@xxxxxxxxxxxxx>
+#
+
+import os
+import pytest
+import shutil
+import subprocess
+import tempfile
+
+conf_path = os.path.abspath(os.path.join('scripts', 'kconfig', 'conf'))
+
+class Conf:
+
+    def __init__(self, request):
+        """Create a new Conf object, which is a scripts/kconfig/conf
+        runner and result checker.
+
+        Arguments:
+        request - object to introspect the requesting test module
+        """
+
+        # the directory of the test being run
+        self.test_dir = os.path.dirname(str(request.fspath))
+
+    def __run_conf(self, mode, dot_config=None, out_file='.config',
+                   interactive=False, in_keys=None, extra_env={}):
+        """Run scripts/kconfig/conf
+
+        mode: input mode option (--oldaskconfig, --defconfig=<file> etc.)
+        dot_config: the .config file for input.
+        out_file: file name to contain the output config data.
+        interactive: flag to specify the interactive mode.
+        in_keys: key inputs for interactive modes.
+        extra_env: additional environment.
+        """
+
+        command = [conf_path, mode, 'Kconfig']
+
+        # Override 'srctree' environment to make the test as the top directory
+        extra_env['srctree'] = self.test_dir
+
+        # scripts/kconfig/conf is run in a temporary directory.
+        # This directory is automatically removed when done.
+        with tempfile.TemporaryDirectory() as temp_dir:
+
+            # if .config is given, copy it to the working directory
+            if dot_config:
+                shutil.copyfile(os.path.join(self.test_dir, dot_config),
+                                os.path.join(temp_dir, '.config'))
+
+            ps = subprocess.Popen(command,
+                                  stdin=subprocess.PIPE,
+                                  stdout=subprocess.PIPE,
+                                  stderr=subprocess.PIPE,
+                                  cwd=temp_dir,
+                                  env=dict(os.environ, **extra_env))
+
+            # If user key input is specified, feed it into stdin.
+            if in_keys:
+                ps.stdin.write(in_keys.encode('utf-8'))
+
+            while ps.poll() == None:
+                # For interactive modes such as 'make config', 'make oldconfig',
+                # send 'Enter' key until the program finishes.
+                if interactive:
+                    ps.stdin.write(b'\n')
+
+            self.retcode = ps.returncode
+            self.stdout = ps.stdout.read().decode()
+            self.stderr = ps.stderr.read().decode()
+
+            # Retrieve the resulted config data only when .config is supposed
+            # to exist.  If the command fails, the .config does not exist.
+            # 'make listnewconfig' does not produce .config in the first place.
+            if self.retcode == 0 and out_file:
+                with open(os.path.join(temp_dir, out_file)) as f:
+                    self.config = f.read()
+            else:
+                self.config = None
+
+        # Logging:
+        # Pytest captures the following information by default.  In failure
+        # of tests, the captured log will be displayed.  This will be useful to
+        # figure out what has happened.
+
+        print("command: {}\n".format(' '.join(command)))
+        print("retcode: {}\n".format(self.retcode))
+
+        if dot_config:
+            print("input .config:".format(dot_config))
+
+        print("stdout:")
+        print(self.stdout)
+        print("stderr:")
+        print(self.stderr)
+
+        if self.config is not None:
+            print("output of {}:".format(out_file))
+            print(self.config)
+
+        return self.retcode
+
+    def oldaskconfig(self, dot_config=None, in_keys=None):
+        """Run oldaskconfig (make config)
+
+        dot_config: the .config file for input (optional).
+        in_key: key inputs (optional).
+        """
+        return self.__run_conf('--oldaskconfig', dot_config=dot_config,
+                             interactive=True, in_keys=in_keys)
+
+    def oldconfig(self, dot_config=None, in_keys=None):
+        """Run oldconfig
+
+        dot_config: the .config file for input (optional).
+        in_key: key inputs (optional).
+        """
+        return self.__run_conf('--oldconfig', dot_config=dot_config,
+                             interactive=True, in_keys=in_keys)
+
+    def defconfig(self, defconfig):
+        """Run defconfig
+
+        defconfig: the defconfig file for input.
+        """
+        defconfig_path = os.path.join(self.test_dir, defconfig)
+        return self.__run_conf('--defconfig={}'.format(defconfig_path))
+
+    def olddefconfig(self, dot_config=None):
+        """Run olddefconfig
+
+        dot_config: the .config file for input (optional).
+        """
+        return self.__run_conf('--olddefconfig', dot_config=dot_config)
+
+    def __allconfig(self, foo, all_config):
+        """Run all*config
+
+        all_config: fragment config file for KCONFIG_ALLCONFIG (optional).
+        """
+        if all_config:
+            all_config_path = os.path.join(self.test_dir, all_config)
+            extra_env = {'KCONFIG_ALLCONFIG': all_config_path}
+        else:
+            extra_env = {}
+
+        return self.__run_conf('--all{}config'.format(foo), extra_env=extra_env)
+
+    def allyesconfig(self, all_config=None):
+        """Run allyesconfig
+        """
+        return self.__allconfig('yes', all_config)
+
+    def allmodconfig(self, all_config=None):
+        """Run allmodconfig
+        """
+        return self.__allconfig('mod', all_config)
+
+    def allnoconfig(self, all_config=None):
+        """Run allnoconfig
+        """
+        return self.__allconfig('no', all_config)
+
+    def alldefconfig(self, all_config=None):
+        """Run alldefconfig
+        """
+        return self.__allconfig('def', all_config)
+
+    def savedefconfig(self, dot_config):
+        """Run savedefconfig
+        """
+        return self.__run_conf('--savedefconfig', out_file='defconfig')
+
+    def listnewconfig(self, dot_config=None):
+        """Run listnewconfig
+        """
+        return self.__run_conf('--listnewconfig', dot_config=dot_config,
+                               out_file=None)
+
+    # checkers
+    def __read_and_compare(self, compare, expected):
+        """Compare the result with expectation.
+
+        Arguments:
+        compare: function to compare the result with expectation
+        expected: file that contains the expected data
+        """
+        with open(os.path.join(self.test_dir, expected)) as f:
+            expected_data = f.read()
+        print(expected_data)
+        return compare(self, expected_data)
+
+    def __contains(self, attr, expected):
+        print("{0} is expected to contain '{1}':".format(attr, expected))
+        return self.__read_and_compare(lambda s, e: getattr(s, attr).find(e) >= 0,
+                                       expected)
+
+    def __matches(self, attr, expected):
+        print("{0} is expected to match '{1}':".format(attr, expected))
+        return self.__read_and_compare(lambda s, e: getattr(s, attr) == e,
+                                       expected)
+
+    def config_contains(self, expected):
+        """Check if resulted configuration contains expected data.
+
+        Arguments:
+        expected: file that contains the expected data.
+        """
+        return self.__contains('config', expected)
+
+    def config_matches(self, expected):
+        """Check if resulted configuration exactly matches expected data.
+
+        Arguments:
+        expected: file that contains the expected data.
+        """
+        return self.__matches('config', expected)
+
+    def stdout_contains(self, expected):
+        """Check if resulted stdout contains expected data.
+
+        Arguments:
+        expected: file that contains the expected data.
+        """
+        return self.__contains('stdout', expected)
+
+    def stdout_matches(self, cmp_file):
+        """Check if resulted stdout exactly matches expected data.
+
+        Arguments:
+        expected: file that contains the expected data.
+        """
+        return self.__matches('stdout', expected)
+
+    def stderr_contains(self, expected):
+        """Check if resulted stderr contains expected data.
+
+        Arguments:
+        expected: file that contains the expected data.
+        """
+        return self.__contains('stderr', expected)
+
+    def stderr_matches(self, cmp_file):
+        """Check if resulted stderr exactly matches expected data.
+
+        Arguments:
+        expected: file that contains the expected data.
+        """
+        return self.__matches('stderr', expected)
+
+@pytest.fixture(scope="module")
+def conf(request):
+    return Conf(request)
diff --git a/scripts/kconfig/tests/pytest.ini b/scripts/kconfig/tests/pytest.ini
new file mode 100644
index 0000000..07b94e0
--- /dev/null
+++ b/scripts/kconfig/tests/pytest.ini
@@ -0,0 +1,6 @@
+[pytest]
+addopts = --verbose
+# Pytest requires that test files have unique names, because pytest imports
+# them as top-level modules.  It is silly to prefix or suffix a test file with
+# the directory name that contains it.  Use __init__.py for all test files.
+python_files = __init__.py
-- 
2.7.4

--
To unsubscribe from this list: send the line "unsubscribe linux-kbuild" in
the body of a message to majordomo@xxxxxxxxxxxxxxx
More majordomo info at  http://vger.kernel.org/majordomo-info.html



[Index of Archives]     [Linux&nblp;USB Development]     [Linux Media]     [Video for Linux]     [Linux Audio Users]     [Yosemite Secrets]     [Linux Kernel]     [Linux SCSI]

  Powered by Linux