diff options
Diffstat (limited to 'kotlin/common')
-rw-r--r-- | kotlin/common/is_eligible_friend.bzl | 2 | ||||
-rw-r--r-- | kotlin/common/testing/BUILD | 34 | ||||
-rw-r--r-- | kotlin/common/testing/analysis.bzl | 114 | ||||
-rw-r--r-- | kotlin/common/testing/asserts.bzl | 116 | ||||
-rw-r--r-- | kotlin/common/testing/testing_rules.bzl | 143 | ||||
-rw-r--r-- | kotlin/common/testing/unittest_suites.bzl | 205 |
6 files changed, 614 insertions, 0 deletions
diff --git a/kotlin/common/is_eligible_friend.bzl b/kotlin/common/is_eligible_friend.bzl index 1e5e814..0cf15d7 100644 --- a/kotlin/common/is_eligible_friend.bzl +++ b/kotlin/common/is_eligible_friend.bzl @@ -16,6 +16,8 @@ load("//:visibility.bzl", "RULES_DEFS_THAT_COMPILE_KOTLIN") +visibility(RULES_DEFS_THAT_COMPILE_KOTLIN) + def is_eligible_friend(target, friend): """ Determines if `target` is allowed to use `internal` members of `friend` diff --git a/kotlin/common/testing/BUILD b/kotlin/common/testing/BUILD new file mode 100644 index 0000000..b3ed4eb --- /dev/null +++ b/kotlin/common/testing/BUILD @@ -0,0 +1,34 @@ +# Copyright 2022 Google LLC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the License); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") + +package( + default_testonly = True, + default_visibility = ["//:internal"], +) + +licenses(["notice"]) + +bzl_library( + name = "testing_bzl", + srcs = glob(["*.bzl"]), + visibility = [ + "//:internal", + ], + deps = [ + "//:visibility_bzl", + "@bazel_skylib//lib:unittest", + ], +) diff --git a/kotlin/common/testing/analysis.bzl b/kotlin/common/testing/analysis.bzl new file mode 100644 index 0000000..34cbfae --- /dev/null +++ b/kotlin/common/testing/analysis.bzl @@ -0,0 +1,114 @@ +# Copyright 2022 Google LLC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the License); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""kt_analysis""" + +load("//:visibility.bzl", "RULES_KOTLIN") + +visibility(RULES_KOTLIN) + +def _get_action(actions, mnemonic): + """Get a specific action + + Args: + actions: [List[Action]] + mnemonic: [string] Identify the action whose args to search + + Returns: + [Action|None] The arg value, or None if it couldn't be found + """ + menmonic_actions = [a for a in actions if a.mnemonic == mnemonic] + if len(menmonic_actions) == 0: + return None + elif len(menmonic_actions) > 1: + fail("Expected a single '%s' action" % mnemonic) + + return menmonic_actions[0] + +def _get_all_args(action, arg_name, style = "trim"): + """Gets values for all instances of an arg name from a specific action. + + Args: + action: [Action|None] + arg_name: [string] + style: ["trim"|"next"|"list"] The style of commandline arg + + Returns: + [list[string]|list[list[string]]|None] The list of matching arg values + """ + if not action: + return [] + + args = action.argv + matches = [(i, a) for (i, a) in enumerate(args) if a.startswith(arg_name)] + + result = [] + for index, arg in matches: + if style == "trim": + result.append(arg[len(arg_name):]) + elif style == "next": + result.append(args[index + 1]) + elif style == "list": + sub_result = [] + for i in range(index + 1, len(args)): + if args[i].startswith("--"): + break + sub_result.append(args[i]) + result.append(sub_result) + else: + fail("Unrecognized arg style '%s" % style) + + return result + +def _get_arg(action, arg_name, style = "trim"): + """Gets values for exactly one instance of an arg name from a specific action. + + Args: + action: [Action|None] + arg_name: [string] + style: ["trim"|"next"|"list"] The style of commandline arg + + Returns: + [string|list[string]|None] The arg value, or None if it couldn't be found + """ + results = _get_all_args(action, arg_name, style) + + if len(results) == 0: + return None + elif len(results) == 1: + return results[0] + else: + fail("Expected a single '%s' arg" % arg_name) + +def _check_endswith_test(ctx): + name = ctx.label.name + for i in range(0, 10): + # TODO: Remove support for suffix digits + if name.endswith(str(i)): + name = name.removesuffix(str(i)) + break + if name.endswith("_test"): + return + + fail("Analysis test names must end in '_test'") + +kt_analysis = struct( + # go/keep-sorted start + DEFAULT_LIST = ["__default__"], + check_endswith_test = _check_endswith_test, + get_action = _get_action, + get_all_args = _get_all_args, + get_arg = _get_arg, + # go/keep-sorted end +) diff --git a/kotlin/common/testing/asserts.bzl b/kotlin/common/testing/asserts.bzl new file mode 100644 index 0000000..d88796c --- /dev/null +++ b/kotlin/common/testing/asserts.bzl @@ -0,0 +1,116 @@ +# Copyright 2022 Google LLC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the License); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""kt_asserts""" + +load("//:visibility.bzl", "RULES_KOTLIN") +load("@bazel_skylib//lib:unittest.bzl", "asserts") + +visibility(RULES_KOTLIN) + +def _equals(a, b): + return a == b + +def _list_matching(left, right, matcher = None): + """Find the overlap between two lists. + + Args: + left: [list[A]] + right: [list[B]] + matcher: [function(A,B):bool] A matcher on the two list types + + Returns: + [(list[A], list[(A, B)], list[B])] The left-only, matching-pair, and right-only lists + """ + + matcher = matcher or _equals + + left_only = [] + matches = [] + right_only = list(right) + + def _process_left_ele(left_ele): + for index, right_ele in enumerate(right_only): + if matcher(left_ele, right_ele): + right_only.pop(index) + matches.append((left_ele, right_ele)) + return + + left_only.append(left_ele) + + for left_ele in left: + _process_left_ele(left_ele) + + return (left_only, matches, right_only) + +def _assert_list_matches(env, expected, actual, matcher = None, items_name = "items"): + """Assert two lists have an exact matching. + + Args: + env: [unittest.env] + expected: [list[A]] + actual: [list[B]] + matcher: [function(A,B):bool] + items_name: [string] The plural noun describing the list items in an error message + + Returns: + [None] Fails if assertion violated + """ + + extra_expected, _, extra_actual = _list_matching(expected, actual, matcher = matcher) + asserts.true( + env, + len(extra_actual) == 0 and len(extra_expected) == 0, + "Unmatched expected {name} {expected}\nUnmatched actual {name} {actual}".format( + name = items_name, + expected = extra_expected, + actual = extra_actual, + ), + ) + +def _assert_required_mnemonic_counts(env, required_mnemonic_counts, actual_actions): + """Assert that some set of menemonics is present/absent within a set of Actions. + + Args: + env: [unittest.env] + required_mnemonic_counts: [dict[string,string]] The menemonics to check -> expected count + actual_actions: [list[Action]] + + Returns: + [None] Fails if assertion violated + """ + + considered_actual_mnemonics = [ + x.mnemonic + for x in actual_actions + # Ignore any mnemonics not mentioned by the user + if (x.mnemonic in required_mnemonic_counts) + ] + + required_mnemonics = [] + for m, c in required_mnemonic_counts.items(): + for _ in range(0, int(c)): + required_mnemonics.append(m) + + _assert_list_matches( + env, + required_mnemonics, + considered_actual_mnemonics, + items_name = "mnemonics", + ) + +kt_asserts = struct( + list_matches = _assert_list_matches, + required_mnemonic_counts = _assert_required_mnemonic_counts, +) diff --git a/kotlin/common/testing/testing_rules.bzl b/kotlin/common/testing/testing_rules.bzl new file mode 100644 index 0000000..35359f7 --- /dev/null +++ b/kotlin/common/testing/testing_rules.bzl @@ -0,0 +1,143 @@ +# Copyright 2022 Google LLC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the License); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""kt_testing_rules""" + +load("//:visibility.bzl", "RULES_KOTLIN") +load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts") +load(":analysis.bzl", "kt_analysis") + +visibility(RULES_KOTLIN) + +# Mark targets that's aren't expected to build, but are needed for analysis test assertions. +_ONLY_FOR_ANALYSIS_TAGS = ["manual", "nobuilder", "notap"] + +def _wrap_for_analysis(inner_rule): + """Wrap an existing rule to make it easier to use in analysis tests. + + Args: + inner_rule: [rule|macro] + + Returns: + [macro] Calls inner_rule with appropate tags, returning the target name + """ + + def wrapper(name, tags = [], **kwargs): + inner_rule( + name = name, + tags = tags + _ONLY_FOR_ANALYSIS_TAGS, + **kwargs + ) + return name + + return wrapper + +_assert_failure_test = analysistest.make( + impl = lambda ctx: _assert_failure_test_impl(ctx), + expect_failure = True, + attrs = dict( + msg_contains = attr.string(mandatory = True), + ), +) + +def _assert_failure_test_impl(ctx): + kt_analysis.check_endswith_test(ctx) + + env = analysistest.begin(ctx) + asserts.expect_failure(env, ctx.attr.msg_contains) + return analysistest.end(env) + +_coverage_instrumentation_test = analysistest.make( + impl = lambda ctx: _coverage_instrumentation_test_impl(ctx), + attrs = dict( + expected_instrumented_file_names = attr.string_list(), + ), + config_settings = { + "//command_line_option:collect_code_coverage": "1", + "//command_line_option:instrument_test_targets": "1", + "//command_line_option:instrumentation_filter": "+", + }, +) + +def _coverage_instrumentation_test_impl(ctx): + env = analysistest.begin(ctx) + target_under_test = analysistest.target_under_test(env) + instrumented_files_info = target_under_test[InstrumentedFilesInfo] + instrumented_files = instrumented_files_info.instrumented_files.to_list() + asserts.equals( + env, + ctx.attr.expected_instrumented_file_names, + [file.basename for file in instrumented_files], + ) + return analysistest.end(env) + +def _create_file(name, content = ""): + """Declare a generated file with optional content. + + Args: + name: [string] The relative file path + content: [string] + + Returns: + [File] The label of the file + """ + + if content.startswith("\n"): + content = content[1:-1] + + native.genrule( + name = "gen_" + name, + outs = [name], + cmd = """ +cat > $@ <<EOF +%s +EOF +""" % content, + ) + + return name + +_create_dir = rule( + implementation = lambda ctx: _create_dir_impl(ctx), + attrs = dict( + subdir = attr.string(), + srcs = attr.label_list(allow_files = True), + ), +) + +def _create_dir_impl(ctx): + dir = ctx.actions.declare_directory(ctx.attr.name) + + command = "mkdir -p {0} " + ("&& cp {1} {0}" if ctx.files.srcs else "# {1}") + ctx.actions.run_shell( + command = command.format( + dir.path + "/" + ctx.attr.subdir, + " ".join([s.path for s in ctx.files.srcs]), + ), + inputs = ctx.files.srcs, + outputs = [dir], + ) + + return [DefaultInfo(files = depset([dir]))] + +kt_testing_rules = struct( + # go/keep-sorted start + ONLY_FOR_ANALYSIS_TAGS = _ONLY_FOR_ANALYSIS_TAGS, + assert_failure_test = _assert_failure_test, + coverage_instrumentation_test = _coverage_instrumentation_test, + create_dir = _wrap_for_analysis(_create_dir), + create_file = _create_file, + wrap_for_analysis = _wrap_for_analysis, + # go/keep-sorted end +) diff --git a/kotlin/common/testing/unittest_suites.bzl b/kotlin/common/testing/unittest_suites.bzl new file mode 100644 index 0000000..b1895bf --- /dev/null +++ b/kotlin/common/testing/unittest_suites.bzl @@ -0,0 +1,205 @@ +# Copyright 2022 Google LLC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the License); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A framework for writing tests of Starlark code with minimal overhead. + +Test cases are written as Starlark functions that will eventually be executed as +individual test targets. Two types of tests are supported: those including their +own assertions, and those expected to call 'fail()'. + +Basic usage looks like: +``` +# unittests.bzl +load("//kotlin/common/testing:unittest_suites.bzl", "kt_unittest_suites") +load("@bazel_skylib//lib:unittest.bzl", "asserts") + +unittests = kt_unittest_suite.create() # Create a new suite in this file. + +def _some_test_case(ctx, env): + # Test logic here + asserts.true(env, 1 == 1) + return [] # Return any declared files + +unittests.expect_finish(_some_test_case) # Include the test case in the suite + +def _some_fail_case(ctx): + # No assertions are allowed in fail cases + _some_logic_that_should_call_fail(ctx) + +unittests.expect_fail(_some_fail_case, "fail message substring") # Expect this case to call fail + +# Generate a pair of rules that will be used for test targets +_test, _fail = unittests.close() # @unused +``` + +``` +// BUILD +load(":unittests.bzl", "unittests") + +# Render each test case as a target in this package +unittests.render( + name = "unittests" +) +``` +""" + +load("//:visibility.bzl", "RULES_KOTLIN") +load("@bazel_skylib//lib:unittest.bzl", "unittest") +load(":testing_rules.bzl", "kt_testing_rules") + +visibility(RULES_KOTLIN) + +def _create(): + """Create a new test suite. + + Returns: + [kt_unittest_suite] An object representing the suite under construction + """ + + test_cases = dict() + rule_holder = [] # Use a list rather than separate vars becase captured vars are final + + def expect_fail(test_case, msg_contains): + """Add a test case to the suite which is expected to call fail. + + Args: + test_case: [function(ctx)] + msg_contains: [string] A substring expected in the failure message + """ + + if rule_holder: + fail("Test suite is closed") + + test_case_name = _fn_name(test_case) + if not test_case_name.startswith("_"): + fail("Test cases must be private '%s'" % test_case_name) + if test_case_name in test_cases: + fail("Existing test case named '%s'" % test_case_name) + + test_cases[test_case_name] = struct( + impl = test_case, + msg_contains = msg_contains, + ) + + def expect_finish(test_case): + """Add a test case to the suite. + + Args: + test_case: [function(ctx,unittest.env):None|list[File]] + """ + + expect_fail(test_case, None) + + def close(): + """Close the suite from expect_finishing new tests. + + The return value must be assigned to '_test, _fail' with an '# @unused' suppression. + + Returns: + [(rule, rule)] + """ + + if rule_holder: + fail("Test suite is closed") + + def test_impl(ctx): + env = unittest.begin(ctx) + + output_files = test_cases[ctx.attr.case_name].impl(ctx, env) or [] + if output_files: + ctx.actions.run_shell( + outputs = output_files, + command = "exit 1", + ) + + return unittest.end(env) + [OutputGroupInfo(_file_sink = depset(output_files))] + + test_rule = unittest.make( + impl = test_impl, + attrs = dict(case_name = attr.string()), + ) + rule_holder.append(test_rule) + + def fail_impl(ctx): + test_cases[ctx.attr.case_name].impl(ctx) + return [] + + fail_rule = rule( + implementation = fail_impl, + attrs = dict(case_name = attr.string()), + ) + rule_holder.append(fail_rule) + + # Rules must be assigned to top-level Starlark vars before being called + return test_rule, fail_rule + + def render(name, tags = [], **kwargs): + """Render the test suite into targets. + + Args: + name: [string] + tags: [list[string]] + **kwargs: Generic rule kwargs + """ + + if not rule_holder: + fail("Test suite is not closed") + test_rule = rule_holder[0] + fail_rule = rule_holder[1] + + test_targets = [] + for test_case_name, test_case_data in test_cases.items(): + target_name = test_case_name.removeprefix("_") + "_test" + test_targets.append(target_name) + + if test_case_data.msg_contains == None: + test_rule( + name = target_name, + tags = tags, + case_name = test_case_name, + **kwargs + ) + else: + fail_rule( + name = test_case_name, + tags = tags + kt_testing_rules.ONLY_FOR_ANALYSIS_TAGS, + case_name = test_case_name, + **kwargs + ) + kt_testing_rules.assert_failure_test( + name = target_name, + target_under_test = test_case_name, + msg_contains = test_case_data.msg_contains, + ) + + native.test_suite( + name = name, + tests = test_targets, + **kwargs + ) + + return struct( + expect_finish = expect_finish, + expect_fail = expect_fail, + close = close, + render = render, + ) + +def _fn_name(rule_or_fn): + parts = str(rule_or_fn).removeprefix("<").removesuffix(">").split(" ") + return parts[0] if (len(parts) == 1) else parts[1] + +kt_unittest_suites = struct( + create = _create, +) |