From 439176768b1beb2a5c06234b8c73a18b00741d26 Mon Sep 17 00:00:00 2001 From: David Aristizabal Date: Wed, 8 Mar 2023 23:06:10 -0700 Subject: [PATCH 1/4] Explore options for unit testing macros. --- integration-tests/macros/test_helpers.sql | 6 + integration-tests/tests/macros/utils_test.sql | 89 ++++++++++ macros/macro_test.sql | 157 ++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 integration-tests/tests/macros/utils_test.sql create mode 100644 macros/macro_test.sql diff --git a/integration-tests/macros/test_helpers.sql b/integration-tests/macros/test_helpers.sql index 097e2b3..8a66562 100644 --- a/integration-tests/macros/test_helpers.sql +++ b/integration-tests/macros/test_helpers.sql @@ -17,3 +17,9 @@ select 1 from (select 1) as t where {{ test_report.succeeded }} {% endif %} {% endmacro %} + +{% macro mock_sanitize(a) %} + {{ return('x') }} +{% endmacro %} + + diff --git a/integration-tests/tests/macros/utils_test.sql b/integration-tests/tests/macros/utils_test.sql new file mode 100644 index 0000000..ee10729 --- /dev/null +++ b/integration-tests/tests/macros/utils_test.sql @@ -0,0 +1,89 @@ +{{ config(tags=['unit-test', 'macro_test', 'postgres']) }} + +{% call dbt_unit_testing.macro_test('sanitize None throws') %} + {# TODO: How can we test expecting macro to raise exception? + {{ dbt_unit_testing.assert_equal(dbt_unit_testing.sanitize(None), '') }} + #} +{% endcall %} + +UNION ALL + +{% call dbt_unit_testing.macro_test('sanitize empty string returns empty string') %} + {{ dbt_unit_testing.assert_equal(dbt_unit_testing.sanitize(''), '') }} +{% endcall %} + +UNION ALL + +{% call dbt_unit_testing.macro_test('sanitize unusual whitespace is replaced by single spaces') %} + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.sanitize('abc d e\t\tgh\n\t\n ij\rk\fl\vm\u2005n\u2007o'), + 'abc d e gh ij k l m n o') }} +{% endcall %} + +UNION ALL + +{% call dbt_unit_testing.macro_test('sanitize whitespace at the ends is trimmed') %} + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.sanitize('\t \nfoo\v\r\f '), 'foo') }} +{% endcall %} + + +{# The tests below explore multiple ways that mocking could be implemented. #} + +UNION ALL + +{% call dbt_unit_testing.macro_test('example A: create mocks and remember to clean up at the end') %} + {% set m = dbt_unit_testing.mock_macro(dbt_unit_testing, 'sanitize', mock_fn = mock_sanitize) %} + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.mock_example('foo'), 'x') }} + {% do m.restore() %} +{% endcall %} + +UNION ALL + +{% call dbt_unit_testing.macro_test('example B: pass mock config in options, test runner cleans up', options={ + 'mocks': [ + (dbt_unit_testing, 'sanitize', mock_sanitize), + ]}) %} + + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.mock_example('foo'), 'x') }} +{% endcall %} + +UNION ALL + +{% call dbt_unit_testing.macro_test('example C: mock using return_value') %} + {% set m = dbt_unit_testing.mock_macro(dbt_unit_testing, 'sanitize', return_value=' happy ') %} + + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.mock_example('foo'), ' happy ') }} + + {{ print('Mock called: %s' % m.calls) }} + {% do m.restore() %} +{% endcall %} + +UNION ALL + +{% call(t) dbt_unit_testing.macro_test_with_t('example D: using t helper, test runner cleans up') %} + {% do t.mock(dbt_unit_testing, 'sanitize', return_value='ttest') %} + {{ t.assert_equal( + dbt_unit_testing.mock_example('foo'), 'ttest') }} +{% endcall %} + +UNION ALL + +{% call(t) dbt_unit_testing.macro_test_with_t('example E: using t helper, mock using call') %} + {% call(s) t.mock(dbt_unit_testing, 'sanitize') -%} + mock with call + {%- do print('sanitize called with %s' % (s|trim)) -%} + {%- endcall %} + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.mock_example('foo'), 'mock with call') }} +{% endcall %} + +UNION ALL + +{% call dbt_unit_testing.macro_test('check that all other tests restored mocks') %} + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.mock_example('abc'), 'abc') }} +{% endcall %} diff --git a/macros/macro_test.sql b/macros/macro_test.sql new file mode 100644 index 0000000..5e11ab9 --- /dev/null +++ b/macros/macro_test.sql @@ -0,0 +1,157 @@ +-- Macros for unit testing macros. +-- +-- The macros in this file are intended for testing macros instead of models. +-- +-- Inspiration for assert_* macros was taken from: +-- https://github.com/yu-iskw/dbt-unittest +-- but instead of raising exceptions on failure, the tests print failure +-- messages and feel much like the model tests that are written +-- with dbt_unit_testing.test() + +-- Runs a unit test. +-- +-- Use with a {% call macro_test... %} test body {% endcall %} from .sql files +-- in the tests directory. +-- +-- If the test body returns anything other than white space, it will +-- be considered a failure, and the content will be printed. +-- +-- Tests written this way are executed with the dbt test command. +{% macro macro_test(description='unspecified', options={}) -%} + {# If caller returns anything, it's considered a failure #} + {%- if execute -%} + + {# Set up mocks specified in options. #} + {% set mocks = [] %} + {% for pkg, macro_name, mock_fn in options.get('mocks', []) %} + {% do mocks.append( + dbt_unit_testing.mock_macro(pkg, macro_name, mock_fn=mock_fn)) %} + {% endfor %} + + {# Run the test. #} + {%- set res = caller()|trim -%} + {%- if res -%} + {{ dbt_unit_testing.println('{RED}TEST: {YELLOW}' ~ description) }} + {{ dbt_unit_testing.println('{RED}ERROR: {YELLOW}' ~ res) }} + select 1 as fail + {%- else -%} + {# Test passed. Return a query that returns zero rows #} + select 1 from (select 1) as t where false + {%- endif -%} + + {# Restore mocks. #} + {% for m in mocks %} + {% do m.restore() %} + {% endfor %} + {%- endif -%} +{%- endmacro %} + + +-- An alternative way to write tests. Each test is given a helper object t +-- with methods for creating mocks that can be tracked and cleaned up +-- automatically at the end of each test. +{% macro macro_test_with_t(description='unspecified', options={}) -%} + {# If caller returns anything, it's considered a failure #} + {%- if execute -%} + {# Run the test. It may call t.mock() to set up mocks. #} + {% set t = dbt_unit_testing._new_t(description, options) %} + {%- set res = caller(t)|trim -%} + {%- if res -%} + {{ dbt_unit_testing.println('{RED}TEST: {YELLOW}' ~ description) }} + {{ dbt_unit_testing.println('{RED}ERROR: {YELLOW}' ~ res) }} + select 1 as fail + {%- else -%} + {# Test passed. Return a query that returns zero rows #} + select 1 from (select 1) as t where false + {%- endif -%} + + {# Restore mocks. #} + {% for m in t.mocks %} + {% do m.restore() %} + {% endfor %} + {%- endif -%} +{%- endmacro %} + +{% macro _new_t(description, options) %} + {% set t = { + 'description': description, + 'options': options, + 'assert_true': dbt_unit_testing.assert_true, + 'assert_equal': dbt_unit_testing.assert_equal, + 'mocks': [], + } %} + + {% call(pkg, name, mock_fn=None, return_value=None) dbt_unit_testing.make_func(t, 'mock') %} + {% if caller and not mock_fn %} + {% set mock_fn = caller %} + {% endif %} + {% do t.mocks.append( + dbt_unit_testing.mock_macro(pkg, name, mock_fn=mock_fn, return_value=return_value) + ) %} + {# TODO: return the mock dict when https://github.com/dbt-labs/dbt-core/issues/7144 is fixed. #} + {% endcall %} + + {{ return(t) }} +{% endmacro %} + +-- Returns an error message if b is not true. +{% macro assert_true(b, description='') -%} + {%- if b is not true -%} + {{ b }} is not true{{ ': %s' % description if description else '' }} + {%- endif -%} +{%- endmacro %} + +-- Returns an error message if the values are not equal. +{% macro assert_equal(actual, expected, description='') -%} + {%- if actual != expected -%} + Values are not equal + actual: {{ actual }} + expected: {{ expected }} + {{ description }} + {%- endif -%} +{%- endmacro %} + +-- Mocks the implementation of a macro and returns a mock object. +{% macro mock_macro(package, macro_name, mock_fn=None, return_value=None) %} + {% set m = { + 'package': package, + 'macro_name': macro_name, + 'return_value': return_value, + 'original_fn': package[macro_name], + 'mock_fn': mock_fn, + 'calls': [], + } %} + + {% call dbt_unit_testing.make_func(m, 'restore') %} + {% do m.package.update({m.macro_name: m.original_fn}) %} + {% endcall %} + + {# If mock_fn is given, use it, otherwise, create a mock_fn + # that returns return_value. #} + {% if not mock_fn %} + {# TODO: Change return to {{ return(m.return_value) }} when + # https://github.com/dbt-labs/dbt-core/issues/7144 is fixed. + # For now, we can only mock return values for strings. + #} + {%- call dbt_unit_testing.make_func(m, 'mock_fn') -%} + {%- set c = { + 'args': varargs, + 'kwargs': kwargs, + } -%} + {%- do m.calls.append(c) -%} + {{ m.return_value }} + {%- endcall -%} + {% endif %} + + {% do package.update({macro_name: m.mock_fn}) %} + + {{ return(m) }} +{% endmacro %} + +{% macro make_func(o, name) %} + {% do o.update({name: caller}) %} +{% endmacro %} + +{% macro mock_example(s) %} + {{ return(dbt_unit_testing.sanitize(s)) }} +{% endmacro %} From ea1bb87f72d038f5fc604c9d2524c66264ae4f09 Mon Sep 17 00:00:00 2001 From: David Aristizabal Date: Thu, 9 Mar 2023 00:01:32 -0700 Subject: [PATCH 2/4] {{ do return }} from mocks when it works --- integration-tests/tests/macros/utils_test.sql | 15 ++++++++++++-- macros/macro_test.sql | 20 +++++++++---------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/integration-tests/tests/macros/utils_test.sql b/integration-tests/tests/macros/utils_test.sql index ee10729..a9c5fc6 100644 --- a/integration-tests/tests/macros/utils_test.sql +++ b/integration-tests/tests/macros/utils_test.sql @@ -64,6 +64,17 @@ UNION ALL UNION ALL +{% call dbt_unit_testing.macro_test('example C.2: return_value can be something other than string') %} + {% set m = dbt_unit_testing.mock_macro(dbt_unit_testing, 'sanitize', return_value=[1, 2]) %} + + {{ dbt_unit_testing.assert_equal( + dbt_unit_testing.mock_example('foo'), [1, 2]) }} + + {% do m.restore() %} +{% endcall %} + +UNION ALL + {% call(t) dbt_unit_testing.macro_test_with_t('example D: using t helper, test runner cleans up') %} {% do t.mock(dbt_unit_testing, 'sanitize', return_value='ttest') %} {{ t.assert_equal( @@ -74,11 +85,11 @@ UNION ALL {% call(t) dbt_unit_testing.macro_test_with_t('example E: using t helper, mock using call') %} {% call(s) t.mock(dbt_unit_testing, 'sanitize') -%} - mock with call {%- do print('sanitize called with %s' % (s|trim)) -%} + {{ return({'mock_dict': 42}) }} {%- endcall %} {{ dbt_unit_testing.assert_equal( - dbt_unit_testing.mock_example('foo'), 'mock with call') }} + dbt_unit_testing.mock_example('foo'), {'mock_dict': 42}) }} {% endcall %} UNION ALL diff --git a/macros/macro_test.sql b/macros/macro_test.sql index 5e11ab9..44fc24f 100644 --- a/macros/macro_test.sql +++ b/macros/macro_test.sql @@ -8,7 +8,7 @@ -- messages and feel much like the model tests that are written -- with dbt_unit_testing.test() --- Runs a unit test. +-- macro_test runs a unit test. -- -- Use with a {% call macro_test... %} test body {% endcall %} from .sql files -- in the tests directory. @@ -85,10 +85,11 @@ {% if caller and not mock_fn %} {% set mock_fn = caller %} {% endif %} - {% do t.mocks.append( - dbt_unit_testing.mock_macro(pkg, name, mock_fn=mock_fn, return_value=return_value) - ) %} - {# TODO: return the mock dict when https://github.com/dbt-labs/dbt-core/issues/7144 is fixed. #} + {% set m = dbt_unit_testing.mock_macro( + pkg, name, mock_fn=mock_fn, return_value=return_value) %} + {% do t.mocks.append(m) %} + {# TODO: return m when https://github.com/dbt-labs/dbt-core/issues/7144 is fixed. #} + {# {{ return(m) }} #} {% endcall %} {{ return(t) }} @@ -129,17 +130,16 @@ {# If mock_fn is given, use it, otherwise, create a mock_fn # that returns return_value. #} {% if not mock_fn %} - {# TODO: Change return to {{ return(m.return_value) }} when - # https://github.com/dbt-labs/dbt-core/issues/7144 is fixed. - # For now, we can only mock return values for strings. - #} {%- call dbt_unit_testing.make_func(m, 'mock_fn') -%} {%- set c = { 'args': varargs, 'kwargs': kwargs, } -%} {%- do m.calls.append(c) -%} - {{ m.return_value }} + {# TODO: Why can I return here unaffected by + # https://github.com/dbt-labs/dbt-core/issues/7144 ? + #} + {{ return(m.return_value) }} {%- endcall -%} {% endif %} From 035fcfa4714253b763ce1e922dcd0614095d0352 Mon Sep 17 00:00:00 2001 From: David Aristizabal Date: Thu, 9 Mar 2023 12:11:35 -0700 Subject: [PATCH 3/4] add comment for make_func --- macros/macro_test.sql | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/macros/macro_test.sql b/macros/macro_test.sql index 44fc24f..c938850 100644 --- a/macros/macro_test.sql +++ b/macros/macro_test.sql @@ -148,6 +148,31 @@ {{ return(m) }} {% endmacro %} +{# make_func creates a function inside the given dict, o, with the given name. + # The implementation of the function is taken from the call body. + # + # This is useful for making dicts that feel like objects. + # + # Example: + # + # {%- macro new_square() -%} + # {%- set square = {'length': 0} -%} + # + # {%- call(length) dbt_unit_testing.make_func(square, 'set_length') -%} + # {%- do square.update({'length': length}) -%} + # {%- endcall -%} + # + # {%- call() dbt_unit_testing.make_func(square, 'area') -%} + # {{ (square.length * square.length) }} + # {%- endcall -%} + # + # {% do return(square) %} + # {%- endmacro -%} + # + # {%- set s = new_square() -%} + # {%- do s.set_length(10) -%} + # {{ square.area() }} + #} {% macro make_func(o, name) %} {% do o.update({name: caller}) %} {% endmacro %} From 71ef9d750c80a8f1bf81f447e57da948e4f9b372 Mon Sep 17 00:00:00 2001 From: David Aristizabal Date: Thu, 9 Mar 2023 15:50:58 -0700 Subject: [PATCH 4/4] assert_raises wishes --- integration-tests/tests/macros/utils_test.sql | 11 +++++++---- macros/macro_test.sql | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/integration-tests/tests/macros/utils_test.sql b/integration-tests/tests/macros/utils_test.sql index a9c5fc6..f22909a 100644 --- a/integration-tests/tests/macros/utils_test.sql +++ b/integration-tests/tests/macros/utils_test.sql @@ -1,9 +1,12 @@ {{ config(tags=['unit-test', 'macro_test', 'postgres']) }} -{% call dbt_unit_testing.macro_test('sanitize None throws') %} - {# TODO: How can we test expecting macro to raise exception? - {{ dbt_unit_testing.assert_equal(dbt_unit_testing.sanitize(None), '') }} - #} +{% call dbt_unit_testing.macro_test('sanitize None raises exception') %} + {# TODO: How can we test expecting macro to raise exception? + # being able to do something like this would be great: + # + # {% call(msg) dbt_unit_testing.assert_raises_compiler_error() %} + # {{ dbt_unit_testing.sanitize(None) }} + # {% endcall %} #} {% endcall %} UNION ALL diff --git a/macros/macro_test.sql b/macros/macro_test.sql index c938850..36537f6 100644 --- a/macros/macro_test.sql +++ b/macros/macro_test.sql @@ -112,6 +112,23 @@ {%- endif -%} {%- endmacro %} +{# TODO: I really wish we could do something like this, but the approach here + # has one major drawback: execution continues through the code under test + # after it tries to raise an exception. Also, mocking only works if the exception + # is raised from a jinja macro (not if it comes from python code). + # + # -- Returns an error message if caller() does not raise a compiler error. + # {% macro assert_raises_compiler_error() -%} + # {%- set m = dbt_unit_testing.mock_macro( + # exceptions, 'raise_compiler_error', return_value='') -%} + # {%- do caller() -%} + # {%- if m.calls|length == 0 -%} + # Expected exception to be raised. + # No exception was raised. + # {%- endif -%} + # {%- endmacro %} + #} + -- Mocks the implementation of a macro and returns a mock object. {% macro mock_macro(package, macro_name, mock_fn=None, return_value=None) %} {% set m = {