Repository: dbt-labs/dbt-core

Issue:
[CT-2070] [Feature] Materialized tests
This one is about making sure tests stay relevant as we expand into low latency scenarios with [Materialized Views](https://github.com/dbt-labs/dbt-core/issues/6911) (MVs).

Prior art can be found in [`dbt-materialize`](https://github.com/MaterializeInc/materialize/blob/main/misc/dbt-materialize/dbt/include/materialize/macros/materializations/test.sql). More details on the benefits [here](https://materialize.com/blog/real-time-data-quality-tests-using-dbt-and-materialize/).

Let's say you materialized a model with a MV, this is `agg_daily_active_users`:

```YAML

{{
    config(
        materialized='materialized_view',
        auto_refresh=true
    )
}}

select
    date_trunc('day', event_at) as date_day,
    count(distinct user_id) as daily_active_users

from raw_app_data.events
```

I'm defining a [test](https://docs.getdbt.com/docs/build/tests#singular-tests) to ensure data quality:

```SQL
select
    date_day,
    daily_active_users,
    current_date() as test_date
from {{ ref('agg_daily_active_users' )}}
where daily_active_users < 0
```

Currently, when `dbt run` is triggered (Wednesday the 8th, 2023 at 4pm Pacific Time), dbt will execute the underlying SQL query and returns the result in the log.

|date_day|daily_active_users|test_date|
|-|-|-|
|2023-02-07|-2|2023-02-08|

Ok I know about this one, there was some returns that were processed in a bizarre way, it should be solved in the coming day so I'm happy to ignore it.

That's excellent when you know the data is not moving when dbt is not looking. But what happens when it is, like it does with MVs? Then I want my tests to be run continuously. What I want is to make that test queryable so I can get the current status of my MV

I could run `dbt test --select agg_daily_active_users` instead. But then I'm forced to switch context between where my data is flowing (my database engine, via the materialized view) and where my test results appear. The best experience for me is to stay in the database for that.

I could run `dbt test --select agg_daily_active_users --store-failures` instead. But that may be slow and expensive, while making the operation a 2 step process (refresh the table, query the table).

What would be nice is to tell dbt that I want my test query materialized as a view (or a MV) instead, via a [configuration](https://docs.getdbt.com/reference/test-configs#test-specific-configurations):

```SQL

{{ config(
    materialized="view"
) }}

select
    date_day,
    daily_active_users,
    current_date() as test_date
from {{ ref('agg_daily_active_users' )}}
where daily_active_users < 0
```

Which allows me to issue to debug my data with the following query:

```SQL
select * from dbt_test__audit.agg_daily_active_users_negative_count
```
Which when I query it tomorrow will give me the following alarming situation, without having to use dbt.

|date_day|daily_active_users|test_date|
|-|-|-|
|2023-02-25|-547|2023-02-09|
|2023-02-07|-2|2023-02-09|
|2023-02-06|-1500|2023-02-09|

### Should we deprecate [`store-failures`](https://docs.getdbt.com/docs/build/tests#storing-test-failures)?

The run level flag? Certainly not. The intent is different. With `--store-failures` I'm deciding at run time to persist my test results, independently of my test materializations.

The test level config? I think setting `materialized="table"` is equivalent to `store_failures = true`. I don't want to start the deprecation process of `store_failures = true` yet though. Let's punt that for later.

In terms of **precedence of configurations:**
- the `materialized` configuration is top level, it takes precedence over `store_failures` and the flag (they are ignored)
  - if the test `materialized` configuration is omitted, the `store_failures` config will take precedence over the presence or absence of the `--store-failures` flag (it is ignored)
    - If the `materialized` configuration is omitted, or the `store_failures` config is `none` or is omitted, the resource will use the value of the `--store-failures` flag (current [behavior](https://docs.getdbt.com/reference/resource-configs/store_failures#description))

### Available materializations

We should support `view` and `table` for now.

Out of scope at the moment, we will support `materialized_view` at some point. Because warehouses may be able to surface the history of MVs (change log), which would allow me to get a list of all the transient errors that happened when I was not looking at the test MV.

### Scope of the configuration

This configuration is a ["tests config"](https://docs.getdbt.com/reference/test-configs). It should be available at every level where a test configuration is offered and respect normal [inheritance rules](https://docs.getdbt.com/reference/configs-and-properties#config-inheritance).

### Describe alternatives you've considered

Just use a model instead of a test. Because that's basically what we're discussing here.

But in my opinion tests are tests not because their output is a collection of rows, a csv, a table or a view. They are tests because of their specific intent, their life cycle, and the fact they don't belong on the DAG. 
I still want to be able to `dbt test`, even when all my tests are materialized as views.



Discussion:
> But in my opinion tests are tests not because their output is a collection of rows, a csv, a table or a view. They are tests because of their specific intent, their life cycle, and the fact they don't belong on the DAG.

Agree that it makes sense to keep tests as a separate concern, even if you can materialize them. In general, this sounds like a great add-on to the existing functionality of `dbt test`! Taking my maintainer hat off for a minute, I wonder if it could get really complicated to persist tests as materialized views in other databases, since the refreshing strategy might need to inherit from that of the referenced objects (though it's possible I'm just missing your point here 🫠).

In `dbt-materialize`, a materialized view is the preferred materialization for the `store_failures` configuration because it'll minimize the effort required to keep the results stored **and** up-to-date. This would be (roughly) equivalent to using a `view`+`index`, though that would require allowing a full `config` block for tests, not just a `materialization` config.

Resolving patch:
diff --git a/core/dbt/contracts/graph/model_config.py b/core/dbt/contracts/graph/model_config.py
--- a/core/dbt/contracts/graph/model_config.py
+++ b/core/dbt/contracts/graph/model_config.py
@@ -216,7 +216,6 @@ class Hook(dbtClassMixin, Replaceable):
 
 @dataclass
 class BaseConfig(AdditionalPropertiesAllowed, Replaceable):
-
     # enable syntax like: config['key']
     def __getitem__(self, key):
         return self.get(key)
@@ -555,12 +554,61 @@ class TestConfig(NodeAndTestConfig):
     # Annotated is used by mashumaro for jsonschema generation
     severity: Annotated[Severity, Pattern(SEVERITY_PATTERN)] = Severity("ERROR")
     store_failures: Optional[bool] = None
+    store_failures_as: Optional[str] = None
     where: Optional[str] = None
     limit: Optional[int] = None
     fail_calc: str = "count(*)"
     warn_if: str = "!= 0"
     error_if: str = "!= 0"
 
+    def __post_init__(self):
+        """
+        The presence of a setting for `store_failures_as` overrides any existing setting for `store_failures`,
+        regardless of level of granularity. If `store_failures_as` is not set, then `store_failures` takes effect.
+        At the time of implementation, `store_failures = True` would always create a table; the user could not
+        configure this. Hence, if `store_failures = True` and `store_failures_as` is not specified, then it
+        should be set to "table" to mimic the existing functionality.
+
+        A side effect of this overriding functionality is that `store_failures_as="view"` at the project
+        level cannot be turned off at the model level without setting both `store_failures_as` and
+        `store_failures`. The former would cascade down and override `store_failures=False`. The proposal
+        is to include "ephemeral" as a value for `store_failures_as`, which effectively sets
+        `store_failures=False`.
+
+        The exception handling for this is tricky. If we raise an exception here, the entire run fails at
+        parse time. We would rather well-formed models run successfully, leaving only exceptions to be rerun
+        if necessary. Hence, the exception needs to be raised in the test materialization. In order to do so,
+        we need to make sure that we go down the `store_failures = True` route with the invalid setting for
+        `store_failures_as`. This results in the `.get()` defaulted to `True` below, instead of a normal
+        dictionary lookup as is done in the `if` block. Refer to the test materialization for the
+        exception that is raise as a result of an invalid value.
+
+        The intention of this block is to behave as if `store_failures_as` is the only setting,
+        but still allow for backwards compatibility for `store_failures`.
+        See https://github.com/dbt-labs/dbt-core/issues/6914 for more information.
+        """
+
+        # if `store_failures_as` is not set, it gets set by `store_failures`
+        # the settings below mimic existing behavior prior to `store_failures_as`
+        get_store_failures_as_map = {
+            True: "table",
+            False: "ephemeral",
+            None: None,
+        }
+
+        # if `store_failures_as` is set, it dictates what `store_failures` gets set to
+        # the settings below overrides whatever `store_failures` is set to by the user
+        get_store_failures_map = {
+            "ephemeral": False,
+            "table": True,
+            "view": True,
+        }
+
+        if self.store_failures_as is None:
+            self.store_failures_as = get_store_failures_as_map[self.store_failures]
+        else:
+            self.store_failures = get_store_failures_map.get(self.store_failures_as, True)
+
     @classmethod
     def same_contents(cls, unrendered: Dict[str, Any], other: Dict[str, Any]) -> bool:
         """This is like __eq__, except it explicitly checks certain fields."""
@@ -572,6 +620,7 @@ def same_contents(cls, unrendered: Dict[str, Any], other: Dict[str, Any]) -> boo
             "warn_if",
             "error_if",
             "store_failures",
+            "store_failures_as",
         ]
 
         seen = set()
diff --git a/core/dbt/contracts/relation.py b/core/dbt/contracts/relation.py
--- a/core/dbt/contracts/relation.py
+++ b/core/dbt/contracts/relation.py
@@ -19,6 +19,7 @@ class RelationType(StrEnum):
     CTE = "cte"
     MaterializedView = "materialized_view"
     External = "external"
+    Ephemeral = "ephemeral"
 
 
 class ComponentName(StrEnum):


Test patch:
diff --git a/core/dbt/include/global_project/macros/materializations/tests/test.sql b/core/dbt/include/global_project/macros/materializations/tests/test.sql
--- a/core/dbt/include/global_project/macros/materializations/tests/test.sql
+++ b/core/dbt/include/global_project/macros/materializations/tests/test.sql
@@ -6,15 +6,27 @@
 
     {% set identifier = model['alias'] %}
     {% set old_relation = adapter.get_relation(database=database, schema=schema, identifier=identifier) %}
+
+    {% set store_failures_as = config.get('store_failures_as') %}
+    -- if `--store-failures` is invoked via command line and `store_failures_as` is not set,
+    -- config.get('store_failures_as', 'table') returns None, not 'table'
+    {% if store_failures_as == none %}{% set store_failures_as = 'table' %}{% endif %}
+    {% if store_failures_as not in ['table', 'view'] %}
+        {{ exceptions.raise_compiler_error(
+            "'" ~ store_failures_as ~ "' is not a valid value for `store_failures_as`. "
+            "Accepted values are: ['ephemeral', 'table', 'view']"
+        ) }}
+    {% endif %}
+
     {% set target_relation = api.Relation.create(
-        identifier=identifier, schema=schema, database=database, type='table') -%} %}
+        identifier=identifier, schema=schema, database=database, type=store_failures_as) -%} %}
 
     {% if old_relation %}
         {% do adapter.drop_relation(old_relation) %}
     {% endif %}
 
     {% call statement(auto_begin=True) %}
-        {{ create_table_as(False, target_relation, sql) }}
+        {{ get_create_sql(target_relation, sql) }}
     {% endcall %}
 
     {% do relations.append(target_relation) %}
diff --git a/core/dbt/parser/generic_test_builders.py b/core/dbt/parser/generic_test_builders.py
--- a/core/dbt/parser/generic_test_builders.py
+++ b/core/dbt/parser/generic_test_builders.py
@@ -101,6 +101,7 @@ class TestBuilder(Generic[Testable]):
         "error_if",
         "fail_calc",
         "store_failures",
+        "store_failures_as",
         "meta",
         "database",
         "schema",
@@ -242,6 +243,10 @@ def severity(self) -> Optional[str]:
     def store_failures(self) -> Optional[bool]:
         return self.config.get("store_failures")
 
+    @property
+    def store_failures_as(self) -> Optional[bool]:
+        return self.config.get("store_failures_as")
+
     @property
     def where(self) -> Optional[str]:
         return self.config.get("where")
@@ -294,6 +299,8 @@ def get_static_config(self):
             config["fail_calc"] = self.fail_calc
         if self.store_failures is not None:
             config["store_failures"] = self.store_failures
+        if self.store_failures_as is not None:
+            config["store_failures_as"] = self.store_failures_as
         if self.meta is not None:
             config["meta"] = self.meta
         if self.database is not None:
diff --git a/tests/adapter/dbt/tests/adapter/store_test_failures_tests/_files.py b/tests/adapter/dbt/tests/adapter/store_test_failures_tests/_files.py
new file mode 100644
--- /dev/null
+++ b/tests/adapter/dbt/tests/adapter/store_test_failures_tests/_files.py
@@ -0,0 +1,150 @@
+SEED__CHIPMUNKS = """
+name,shirt
+alvin,red
+simon,blue
+theodore,green
+dave,
+""".strip()
+
+
+MODEL__CHIPMUNKS = """
+{{ config(materialized='table') }}
+select *
+from {{ ref('chipmunks_stage') }}
+"""
+
+
+TEST__VIEW_TRUE = """
+{{ config(store_failures_as="view", store_failures=True) }}
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'green'
+"""
+
+
+TEST__VIEW_FALSE = """
+{{ config(store_failures_as="view", store_failures=False) }}
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'green'
+"""
+
+
+TEST__VIEW_UNSET = """
+{{ config(store_failures_as="view") }}
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'green'
+"""
+
+
+TEST__TABLE_TRUE = """
+{{ config(store_failures_as="table", store_failures=True) }}
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'green'
+"""
+
+
+TEST__TABLE_FALSE = """
+{{ config(store_failures_as="table", store_failures=False) }}
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'green'
+"""
+
+
+TEST__TABLE_UNSET = """
+{{ config(store_failures_as="table") }}
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'green'
+"""
+
+
+TEST__EPHEMERAL_TRUE = """
+{{ config(store_failures_as="ephemeral", store_failures=True) }}
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'green'
+"""
+
+
+TEST__EPHEMERAL_FALSE = """
+{{ config(store_failures_as="ephemeral", store_failures=False) }}
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'green'
+"""
+
+
+TEST__EPHEMERAL_UNSET = """
+{{ config(store_failures_as="ephemeral") }}
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'green'
+"""
+
+
+TEST__UNSET_TRUE = """
+{{ config(store_failures=True) }}
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'green'
+"""
+
+
+TEST__UNSET_FALSE = """
+{{ config(store_failures=False) }}
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'green'
+"""
+
+
+TEST__UNSET_UNSET = """
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'green'
+"""
+
+
+TEST__VIEW_UNSET_PASS = """
+{{ config(store_failures_as="view") }}
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'purple'
+"""
+
+
+TEST__ERROR_UNSET = """
+{{ config(store_failures_as="error") }}
+select *
+from {{ ref('chipmunks') }}
+where shirt = 'green'
+"""
+
+
+SCHEMA_YML = """
+version: 2
+
+models:
+  - name: chipmunks
+    columns:
+      - name: name
+        tests:
+          - not_null:
+              store_failures_as: view
+          - accepted_values:
+              store_failures: false
+              store_failures_as: table
+              values:
+                - alvin
+                - simon
+                - theodore
+      - name: shirt
+        tests:
+          - not_null:
+              store_failures: true
+              store_failures_as: view
+"""
diff --git a/tests/adapter/dbt/tests/adapter/store_test_failures_tests/basic.py b/tests/adapter/dbt/tests/adapter/store_test_failures_tests/basic.py
new file mode 100644
--- /dev/null
+++ b/tests/adapter/dbt/tests/adapter/store_test_failures_tests/basic.py
@@ -0,0 +1,305 @@
+from collections import namedtuple
+from typing import Set
+
+import pytest
+
+from dbt.contracts.results import TestStatus
+from dbt.tests.util import run_dbt, check_relation_types
+
+from dbt.tests.adapter.store_test_failures_tests import _files
+
+
+TestResult = namedtuple("TestResult", ["name", "status", "type"])
+
+
+class StoreTestFailuresAsBase:
+    seed_table: str = "chipmunks_stage"
+    model_table: str = "chipmunks"
+    audit_schema_suffix: str = "dbt_test__audit"
+
+    audit_schema: str
+
+    @pytest.fixture(scope="class", autouse=True)
+    def setup_class(self, project):
+        # the seed doesn't get touched, load it once
+        run_dbt(["seed"])
+        yield
+
+    @pytest.fixture(scope="function", autouse=True)
+    def setup_method(self, project, setup_class):
+        # make sure the model is always right
+        run_dbt(["run"])
+
+        # the name of the audit schema doesn't change in a class, but this doesn't run at the class level
+        self.audit_schema = f"{project.test_schema}_{self.audit_schema_suffix}"
+        yield
+
+    @pytest.fixture(scope="function", autouse=True)
+    def teardown_method(self, project):
+        yield
+
+        # clear out the audit schema after each test case
+        with project.adapter.connection_named("__test"):
+            audit_schema = project.adapter.Relation.create(
+                database=project.database, schema=self.audit_schema
+            )
+            project.adapter.drop_schema(audit_schema)
+
+    @pytest.fixture(scope="class")
+    def seeds(self):
+        return {f"{self.seed_table}.csv": _files.SEED__CHIPMUNKS}
+
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {f"{self.model_table}.sql": _files.MODEL__CHIPMUNKS}
+
+    def run_and_assert(
+        self, project, expected_results: Set[TestResult], expect_pass: bool = False
+    ) -> None:
+        """
+        Run `dbt test` and assert the results are the expected results
+
+        Args:
+            project: the `project` fixture; needed since we invoke `run_dbt`
+            expected_results: the expected results of the tests as instances of TestResult
+            expect_pass: passed directly into `run_dbt`; this is only needed if all expected results are tests that pass
+
+        Returns:
+            the row count as an integer
+        """
+        # run the tests
+        results = run_dbt(["test"], expect_pass=expect_pass)
+
+        # show that the statuses are what we expect
+        actual = {(result.node.name, result.status) for result in results}
+        expected = {(result.name, result.status) for result in expected_results}
+        assert actual == expected
+
+        # show that the results are persisted in the correct database objects
+        check_relation_types(
+            project.adapter, {result.name: result.type for result in expected_results}
+        )
+
+
+class StoreTestFailuresAsInteractions(StoreTestFailuresAsBase):
+    """
+    These scenarios test interactions between `store_failures` and `store_failures_as` at the model level.
+    Granularity (e.g. setting one at the project level and another at the model level) is not considered.
+
+    Test Scenarios:
+
+    - If `store_failures_as = "view"` and `store_failures = True`, then store the failures in a view.
+    - If `store_failures_as = "view"` and `store_failures = False`, then store the failures in a view.
+    - If `store_failures_as = "view"` and `store_failures` is not set, then store the failures in a view.
+    - If `store_failures_as = "table"` and `store_failures = True`, then store the failures in a table.
+    - If `store_failures_as = "table"` and `store_failures = False`, then store the failures in a table.
+    - If `store_failures_as = "table"` and `store_failures` is not set, then store the failures in a table.
+    - If `store_failures_as = "ephemeral"` and `store_failures = True`, then do not store the failures.
+    - If `store_failures_as = "ephemeral"` and `store_failures = False`, then do not store the failures.
+    - If `store_failures_as = "ephemeral"` and `store_failures` is not set, then do not store the failures.
+    - If `store_failures_as` is not set and `store_failures = True`, then store the failures in a table.
+    - If `store_failures_as` is not set and `store_failures = False`, then do not store the failures.
+    - If `store_failures_as` is not set and `store_failures` is not set, then do not store the failures.
+    """
+
+    @pytest.fixture(scope="class")
+    def tests(self):
+        return {
+            "view_unset_pass.sql": _files.TEST__VIEW_UNSET_PASS,  # control
+            "view_true.sql": _files.TEST__VIEW_TRUE,
+            "view_false.sql": _files.TEST__VIEW_FALSE,
+            "view_unset.sql": _files.TEST__VIEW_UNSET,
+            "table_true.sql": _files.TEST__TABLE_TRUE,
+            "table_false.sql": _files.TEST__TABLE_FALSE,
+            "table_unset.sql": _files.TEST__TABLE_UNSET,
+            "ephemeral_true.sql": _files.TEST__EPHEMERAL_TRUE,
+            "ephemeral_false.sql": _files.TEST__EPHEMERAL_FALSE,
+            "ephemeral_unset.sql": _files.TEST__EPHEMERAL_UNSET,
+            "unset_true.sql": _files.TEST__UNSET_TRUE,
+            "unset_false.sql": _files.TEST__UNSET_FALSE,
+            "unset_unset.sql": _files.TEST__UNSET_UNSET,
+        }
+
+    def test_tests_run_successfully_and_are_stored_as_expected(self, project):
+        expected_results = {
+            TestResult("view_unset_pass", TestStatus.Pass, "view"),  # control
+            TestResult("view_true", TestStatus.Fail, "view"),
+            TestResult("view_false", TestStatus.Fail, "view"),
+            TestResult("view_unset", TestStatus.Fail, "view"),
+            TestResult("table_true", TestStatus.Fail, "table"),
+            TestResult("table_false", TestStatus.Fail, "table"),
+            TestResult("table_unset", TestStatus.Fail, "table"),
+            TestResult("ephemeral_true", TestStatus.Fail, None),
+            TestResult("ephemeral_false", TestStatus.Fail, None),
+            TestResult("ephemeral_unset", TestStatus.Fail, None),
+            TestResult("unset_true", TestStatus.Fail, "table"),
+            TestResult("unset_false", TestStatus.Fail, None),
+            TestResult("unset_unset", TestStatus.Fail, None),
+        }
+        self.run_and_assert(project, expected_results)
+
+
+class StoreTestFailuresAsProjectLevelOff(StoreTestFailuresAsBase):
+    """
+    These scenarios test that `store_failures_as` at the model level takes precedence over `store_failures`
+    at the project level.
+
+    Test Scenarios:
+
+    - If `store_failures = False` in the project and `store_failures_as = "view"` in the model,
+    then store the failures in a view.
+    - If `store_failures = False` in the project and `store_failures_as = "table"` in the model,
+    then store the failures in a table.
+    - If `store_failures = False` in the project and `store_failures_as = "ephemeral"` in the model,
+    then do not store the failures.
+    - If `store_failures = False` in the project and `store_failures_as` is not set,
+    then do not store the failures.
+    """
+
+    @pytest.fixture(scope="class")
+    def tests(self):
+        return {
+            "results_view.sql": _files.TEST__VIEW_UNSET,
+            "results_table.sql": _files.TEST__TABLE_UNSET,
+            "results_ephemeral.sql": _files.TEST__EPHEMERAL_UNSET,
+            "results_unset.sql": _files.TEST__UNSET_UNSET,
+        }
+
+    @pytest.fixture(scope="class")
+    def project_config_update(self):
+        return {"tests": {"store_failures": False}}
+
+    def test_tests_run_successfully_and_are_stored_as_expected(self, project):
+        expected_results = {
+            TestResult("results_view", TestStatus.Fail, "view"),
+            TestResult("results_table", TestStatus.Fail, "table"),
+            TestResult("results_ephemeral", TestStatus.Fail, None),
+            TestResult("results_unset", TestStatus.Fail, None),
+        }
+        self.run_and_assert(project, expected_results)
+
+
+class StoreTestFailuresAsProjectLevelView(StoreTestFailuresAsBase):
+    """
+    These scenarios test that `store_failures_as` at the project level takes precedence over `store_failures`
+    at the model level.
+
+    Test Scenarios:
+
+    - If `store_failures_as = "view"` in the project and `store_failures = False` in the model,
+    then store the failures in a view.
+    - If `store_failures_as = "view"` in the project and `store_failures = True` in the model,
+    then store the failures in a view.
+    - If `store_failures_as = "view"` in the project and `store_failures` is not set,
+    then store the failures in a view.
+    """
+
+    @pytest.fixture(scope="class")
+    def tests(self):
+        return {
+            "results_true.sql": _files.TEST__VIEW_TRUE,
+            "results_false.sql": _files.TEST__VIEW_FALSE,
+            "results_unset.sql": _files.TEST__VIEW_UNSET,
+        }
+
+    @pytest.fixture(scope="class")
+    def project_config_update(self):
+        return {"tests": {"store_failures_as": "view"}}
+
+    def test_tests_run_successfully_and_are_stored_as_expected(self, project):
+        expected_results = {
+            TestResult("results_true", TestStatus.Fail, "view"),
+            TestResult("results_false", TestStatus.Fail, "view"),
+            TestResult("results_unset", TestStatus.Fail, "view"),
+        }
+        self.run_and_assert(project, expected_results)
+
+
+class StoreTestFailuresAsProjectLevelEphemeral(StoreTestFailuresAsBase):
+    """
+    This scenario tests that `store_failures_as` at the project level takes precedence over `store_failures`
+    at the model level. In particular, setting `store_failures_as = "ephemeral"` at the project level
+    turns off `store_failures` regardless of the setting of `store_failures` anywhere. Turning `store_failures`
+    back on at the model level requires `store_failures_as` to be set at the model level.
+
+    Test Scenarios:
+
+    - If `store_failures_as = "ephemeral"` in the project and `store_failures = True` in the project,
+    then do not store the failures.
+    - If `store_failures_as = "ephemeral"` in the project and `store_failures = True` in the project and the model,
+    then do not store the failures.
+    - If `store_failures_as = "ephemeral"` in the project and `store_failures_as = "view"` in the model,
+    then store the failures in a view.
+    """
+
+    @pytest.fixture(scope="class")
+    def tests(self):
+        return {
+            "results_unset.sql": _files.TEST__UNSET_UNSET,
+            "results_true.sql": _files.TEST__UNSET_TRUE,
+            "results_view.sql": _files.TEST__VIEW_UNSET,
+        }
+
+    @pytest.fixture(scope="class")
+    def project_config_update(self):
+        return {"tests": {"store_failures_as": "ephemeral", "store_failures": True}}
+
+    def test_tests_run_successfully_and_are_stored_as_expected(self, project):
+        expected_results = {
+            TestResult("results_unset", TestStatus.Fail, None),
+            TestResult("results_true", TestStatus.Fail, None),
+            TestResult("results_view", TestStatus.Fail, "view"),
+        }
+        self.run_and_assert(project, expected_results)
+
+
+class StoreTestFailuresAsGeneric(StoreTestFailuresAsBase):
+    """
+    This tests that `store_failures_as` works with generic tests.
+    Test Scenarios:
+
+    - If `store_failures_as = "view"` is used with the `not_null` test in the model, then store the failures in a view.
+    """
+
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            f"{self.model_table}.sql": _files.MODEL__CHIPMUNKS,
+            "schema.yml": _files.SCHEMA_YML,
+        }
+
+    def test_tests_run_successfully_and_are_stored_as_expected(self, project):
+        expected_results = {
+            # `store_failures` unset, `store_failures_as = "view"`
+            TestResult("not_null_chipmunks_name", TestStatus.Pass, "view"),
+            # `store_failures = False`, `store_failures_as = "table"`
+            TestResult(
+                "accepted_values_chipmunks_name__alvin__simon__theodore", TestStatus.Fail, "table"
+            ),
+            # `store_failures = True`, `store_failures_as = "view"`
+            TestResult("not_null_chipmunks_shirt", TestStatus.Fail, "view"),
+        }
+        self.run_and_assert(project, expected_results)
+
+
+class StoreTestFailuresAsExceptions(StoreTestFailuresAsBase):
+    """
+    This tests that `store_failures_as` raises exceptions in appropriate scenarios.
+    Test Scenarios:
+
+    - If `store_failures_as = "error"`, a helpful exception is raised.
+    """
+
+    @pytest.fixture(scope="class")
+    def tests(self):
+        return {
+            "store_failures_as_error.sql": _files.TEST__ERROR_UNSET,
+        }
+
+    def test_tests_run_unsuccessfully_and_raise_appropriate_exception(self, project):
+        results = run_dbt(["test"], expect_pass=False)
+        assert len(results) == 1
+        result = results[0]
+        assert "Compilation Error" in result.message
+        assert "'error' is not a valid value" in result.message
+        assert "Accepted values are: ['ephemeral', 'table', 'view']" in result.message
diff --git a/tests/functional/artifacts/expected_manifest.py b/tests/functional/artifacts/expected_manifest.py
--- a/tests/functional/artifacts/expected_manifest.py
+++ b/tests/functional/artifacts/expected_manifest.py
@@ -132,6 +132,7 @@ def get_rendered_tst_config(**updates):
         "tags": [],
         "severity": "ERROR",
         "store_failures": None,
+        "store_failures_as": None,
         "warn_if": "!= 0",
         "error_if": "!= 0",
         "fail_calc": "count(*)",
diff --git a/tests/functional/list/test_list.py b/tests/functional/list/test_list.py
--- a/tests/functional/list/test_list.py
+++ b/tests/functional/list/test_list.py
@@ -493,6 +493,7 @@ def expect_test_output(self):
                         "materialized": "test",
                         "severity": "ERROR",
                         "store_failures": None,
+                        "store_failures_as": None,
                         "warn_if": "!= 0",
                         "error_if": "!= 0",
                         "fail_calc": "count(*)",
@@ -520,6 +521,7 @@ def expect_test_output(self):
                         "materialized": "test",
                         "severity": "ERROR",
                         "store_failures": None,
+                        "store_failures_as": None,
                         "warn_if": "!= 0",
                         "error_if": "!= 0",
                         "fail_calc": "count(*)",
@@ -550,6 +552,7 @@ def expect_test_output(self):
                         "materialized": "test",
                         "severity": "ERROR",
                         "store_failures": None,
+                        "store_failures_as": None,
                         "warn_if": "!= 0",
                         "error_if": "!= 0",
                         "fail_calc": "count(*)",
diff --git a/tests/functional/store_test_failures/test_store_test_failures.py b/tests/functional/store_test_failures/test_store_test_failures.py
new file mode 100644
--- /dev/null
+++ b/tests/functional/store_test_failures/test_store_test_failures.py
@@ -0,0 +1,46 @@
+import pytest
+
+from dbt.tests.adapter.store_test_failures_tests.basic import (
+    StoreTestFailuresAsInteractions,
+    StoreTestFailuresAsProjectLevelOff,
+    StoreTestFailuresAsProjectLevelView,
+    StoreTestFailuresAsProjectLevelEphemeral,
+    StoreTestFailuresAsGeneric,
+    StoreTestFailuresAsExceptions,
+)
+
+
+class PostgresMixin:
+    audit_schema: str
+
+    @pytest.fixture(scope="function", autouse=True)
+    def setup_audit_schema(self, project, setup_method):
+        # postgres only supports schema names of 63 characters
+        # a schema with a longer name still gets created, but the name gets truncated
+        self.audit_schema = self.audit_schema[:63]
+
+
+class TestStoreTestFailuresAsInteractions(StoreTestFailuresAsInteractions, PostgresMixin):
+    pass
+
+
+class TestStoreTestFailuresAsProjectLevelOff(StoreTestFailuresAsProjectLevelOff, PostgresMixin):
+    pass
+
+
+class TestStoreTestFailuresAsProjectLevelView(StoreTestFailuresAsProjectLevelView, PostgresMixin):
+    pass
+
+
+class TestStoreTestFailuresAsProjectLevelEphemeral(
+    StoreTestFailuresAsProjectLevelEphemeral, PostgresMixin
+):
+    pass
+
+
+class TestStoreTestFailuresAsGeneric(StoreTestFailuresAsGeneric, PostgresMixin):
+    pass
+
+
+class TestStoreTestFailuresAsExceptions(StoreTestFailuresAsExceptions, PostgresMixin):
+    pass