Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Explore options for unit testing macros. #122

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions integration-tests/macros/test_helpers.sql
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@
select 1 from (select 1) as t where {{ test_report.succeeded }}
{% endif %}
{% endmacro %}

{% macro mock_sanitize(a) %}
{{ return('x') }}
{% endmacro %}


103 changes: 103 additions & 0 deletions integration-tests/tests/macros/utils_test.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
{{ config(tags=['unit-test', 'macro_test', 'postgres']) }}

{% 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

{% 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 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(
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') -%}
{%- 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_dict': 42}) }}
{% 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 %}
199 changes: 199 additions & 0 deletions macros/macro_test.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
-- 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()

-- macro_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 %}
{% 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) }}
{% 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 %}

{# 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 = {
'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 %}
{%- call dbt_unit_testing.make_func(m, 'mock_fn') -%}
{%- set c = {
'args': varargs,
'kwargs': kwargs,
} -%}
{%- do m.calls.append(c) -%}
{# TODO: Why can I return here unaffected by
# https://github.com/dbt-labs/dbt-core/issues/7144 ?
#}
{{ return(m.return_value) }}
{%- endcall -%}
{% endif %}

{% do package.update({macro_name: m.mock_fn}) %}

{{ 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 %}

{% macro mock_example(s) %}
{{ return(dbt_unit_testing.sanitize(s)) }}
{% endmacro %}
Loading