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

Message ID 1517877294-4826-8-git-send-email-yamada.masahiro@socionext.com
State New
Headers show
Series
  • Add Kconfig unit tests
Related show

Commit Message

Masahiro Yamada Feb. 6, 2018, 12:34 a.m.
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@socionext.com>

---

 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

-- 
2.7.4

Comments

Ulf Magnusson Feb. 8, 2018, 12:35 a.m. | #1
On Tue, Feb 6, 2018 at 1:34 AM, Masahiro Yamada
<yamada.masahiro@socionext.com> wrote:
> 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@socionext.com>

> ---

>

>  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@socionext.com>

> +#

> +

> +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

>


Reviewed-by: Ulf Magnusson <ulfalizer@gmail.com>

Patch

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@socionext.com>
+#
+
+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