Repository: dbt-labs/dbt-core

Issue:
[CT-3039] Ensure that list command works for unit_tests and has reasonable output
### Description

From https://github.com/dbt-labs/dbt-core/issues/8651 and https://github.com/dbt-labs/dbt-core/issues/8606

We should be able to list unit tests in a project using `--select test_type:unit`.

### Acceptance Criteria 

List command works for unit tests and has reasonable output.  
`dbt list --select test_type:unit`                     
`dbt list --select test_type:data`      

`dbt list --resource-type test` to return all data and all unit tests                                                        
                                                                                                                                                         
### Impact to other teams
                  
None                 
                                
### Will backports be required?
                                                                                                         
No              

### Context


Discussion:
Looks like `dbt list` doesn't work with the test_type: resource selector as of yet: 

```
❯ dbt build -s +my_model,test_type:unit
16:42:55  Running with dbt=1.8.0-a1
16:42:55  target not specified in profile 'postgres', using 'default'
16:42:55  Registered adapter: postgres=1.8.0-a1
16:42:55  Found 8 models, 3 seeds, 20 tests, 0 sources, 0 exposures, 0 metrics, 410 macros, 0 groups, 0 semantic models, 5 unit tests
16:42:55  
16:42:56  Concurrency: 1 threads (target='default')
16:42:56  
16:42:56  1 of 5 START unit_test test_my_model ........................................... [RUN]
16:42:56  1 of 5 PASS test_my_model ...................................................... [PASS in 0.12s]
16:42:56  2 of 5 START unit_test test_my_model_datetime .................................. [RUN]
16:42:56  2 of 5 PASS test_my_model_datetime ............................................. [PASS in 0.11s]
16:42:56  3 of 5 START unit_test test_my_model_empty ..................................... [RUN]
16:42:56  3 of 5 PASS test_my_model_empty ................................................ [PASS in 0.08s]
16:42:56  4 of 5 START unit_test test_my_model_overrides ................................. [RUN]
16:42:56  4 of 5 PASS test_my_model_overrides ............................................ [PASS in 0.07s]
16:42:56  5 of 5 START unit_test test_my_model_string_concat ............................. [RUN]
16:42:56  5 of 5 PASS test_my_model_string_concat ........................................ [PASS in 0.07s]
16:42:56  
16:42:56  Finished running 5 unit_tests in 0 hours 0 minutes and 1.00 seconds (1.00s).
16:42:56  
16:42:56  Completed successfully
16:42:56  
16:42:56  Done. PASS=5 WARN=0 ERROR=0 SKIP=0 TOTAL=5
```

```
❯ dbt ls -s +my_model,test_type:unit
16:43:05  Running with dbt=1.8.0-a1
16:43:05  target not specified in profile 'postgres', using 'default'
16:43:05  Registered adapter: postgres=1.8.0-a1
16:43:05  Found 8 models, 3 seeds, 20 tests, 0 sources, 0 exposures, 0 metrics, 410 macros, 0 groups, 0 semantic models, 5 unit tests
16:43:05  No nodes selected!
```

```
❯ dbt ls -s test_type:unit
16:48:01  Running with dbt=1.8.0-a1
16:48:01  target not specified in profile 'postgres', using 'default'
16:48:01  Registered adapter: postgres=1.8.0-a1
16:48:01  Found 8 models, 3 seeds, 20 tests, 0 sources, 0 exposures, 0 metrics, 410 macros, 0 groups, 0 semantic models, 5 unit tests
16:48:01  No nodes selected!
```
If you have a `models/_models.yml` file like this:

```yaml
models:
  - name: my_model
    columns:
      - name: id
        tests:
          - unique
    versions:    
      - v: 1
      - v: 2
```

Then here is the output of `dbt list`:

```
(dbt_1.7) $ dbt ls -s +my_model,resource_type:test

00:41:29  Running with dbt=1.7.4
00:41:30  Registered adapter: postgres=1.7.4
00:41:30  Found 3 models, 1 snapshot, 1 analysis, 1 seed, 2 tests, 1 source, 0 exposures, 1 metric, 401 macros, 0 groups, 1 semantic model
my_project.unique_my_model_v1_id
my_project.unique_my_model_v2_id
```

Note that there are 2 tests and they each have a unique node name.

In a group discussion yesterday about https://github.com/dbt-labs/dbt-core/issues/9344, we considered what should happen if there is a single unit test with two versions.

We decided that it should behave similarly and output two nodes:
```
00:41:30  Found .. 2 unit tests ...
my_project.my_unit_test_my_model_v1
my_project.my_unit_test_my_model_v2
```

Resolving patch:
diff --git a/core/dbt/contracts/graph/nodes.py b/core/dbt/contracts/graph/nodes.py
--- a/core/dbt/contracts/graph/nodes.py
+++ b/core/dbt/contracts/graph/nodes.py
@@ -1091,6 +1091,13 @@ def tags(self) -> List[str]:
         tags = self.config.tags
         return [tags] if isinstance(tags, str) else tags
 
+    @property
+    def versioned_name(self) -> str:
+        versioned_name = self.name
+        if self.version is not None:
+            versioned_name += f"_v{self.version}"
+        return versioned_name
+
     def build_unit_test_checksum(self):
         # everything except 'description'
         data = f"{self.model}-{self.versions}-{self.given}-{self.expect}-{self.overrides}"
diff --git a/core/dbt/task/list.py b/core/dbt/task/list.py
--- a/core/dbt/task/list.py
+++ b/core/dbt/task/list.py
@@ -1,6 +1,12 @@
 import json
 
-from dbt.contracts.graph.nodes import Exposure, SourceDefinition, Metric, SemanticModel
+from dbt.contracts.graph.nodes import (
+    Exposure,
+    SourceDefinition,
+    Metric,
+    SemanticModel,
+    UnitTestDefinition,
+)
 from dbt.flags import get_flags
 from dbt.graph import ResourceTypeSelector
 from dbt.task.runnable import GraphRunnableTask
@@ -26,6 +32,7 @@ class ListTask(GraphRunnableTask):
             NodeType.Exposure,
             NodeType.Metric,
             NodeType.SemanticModel,
+            NodeType.Unit,
         )
     )
     ALL_RESOURCE_VALUES = DEFAULT_RESOURCE_VALUES | frozenset((NodeType.Analysis,))
@@ -57,26 +64,28 @@ def __init__(self, args, config, manifest) -> None:
     def _iterate_selected_nodes(self):
         selector = self.get_node_selector()
         spec = self.get_selection_spec()
-        nodes = sorted(selector.get_selected(spec))
-        if not nodes:
+        unique_ids = sorted(selector.get_selected(spec))
+        if not unique_ids:
             warn_or_error(NoNodesSelected())
             return
         if self.manifest is None:
             raise DbtInternalError("manifest is None in _iterate_selected_nodes")
-        for node in nodes:
-            if node in self.manifest.nodes:
-                yield self.manifest.nodes[node]
-            elif node in self.manifest.sources:
-                yield self.manifest.sources[node]
-            elif node in self.manifest.exposures:
-                yield self.manifest.exposures[node]
-            elif node in self.manifest.metrics:
-                yield self.manifest.metrics[node]
-            elif node in self.manifest.semantic_models:
-                yield self.manifest.semantic_models[node]
+        for unique_id in unique_ids:
+            if unique_id in self.manifest.nodes:
+                yield self.manifest.nodes[unique_id]
+            elif unique_id in self.manifest.sources:
+                yield self.manifest.sources[unique_id]
+            elif unique_id in self.manifest.exposures:
+                yield self.manifest.exposures[unique_id]
+            elif unique_id in self.manifest.metrics:
+                yield self.manifest.metrics[unique_id]
+            elif unique_id in self.manifest.semantic_models:
+                yield self.manifest.semantic_models[unique_id]
+            elif unique_id in self.manifest.unit_tests:
+                yield self.manifest.unit_tests[unique_id]
             else:
                 raise DbtRuntimeError(
-                    f'Got an unexpected result from node selection: "{node}"'
+                    f'Got an unexpected result from node selection: "{unique_id}"'
                     f"Expected a source or a node!"
                 )
 
@@ -101,6 +110,10 @@ def generate_selectors(self):
                 assert isinstance(node, SemanticModel)
                 semantic_model_selector = ".".join([node.package_name, node.name])
                 yield f"semantic_model:{semantic_model_selector}"
+            elif node.resource_type == NodeType.Unit:
+                assert isinstance(node, UnitTestDefinition)
+                unit_test_selector = ".".join([node.package_name, node.versioned_name])
+                yield f"unit_test:{unit_test_selector}"
             else:
                 # everything else is from `fqn`
                 yield ".".join(node.fqn)


Test patch:
diff --git a/core/dbt/task/test.py b/core/dbt/task/test.py
--- a/core/dbt/task/test.py
+++ b/core/dbt/task/test.py
@@ -83,9 +83,7 @@ class TestRunner(CompileRunner):
 
     def describe_node_name(self):
         if self.node.resource_type == NodeType.Unit:
-            name = f"{self.node.model}::{self.node.name}"
-            if self.node.version is not None:
-                name = name + f"_v{self.node.version}"
+            name = f"{self.node.model}::{self.node.versioned_name}"
             return name
         else:
             return self.node.name
diff --git a/tests/functional/unit_testing/test_ut_list.py b/tests/functional/unit_testing/test_ut_list.py
new file mode 100644
--- /dev/null
+++ b/tests/functional/unit_testing/test_ut_list.py
@@ -0,0 +1,82 @@
+import pytest
+from dbt.tests.util import run_dbt
+import json
+import os
+
+from fixtures import (  # noqa: F401
+    my_model_vars_sql,
+    my_model_a_sql,
+    my_model_b_sql,
+    test_my_model_yml,
+    datetime_test,
+)
+
+
+class TestUnitTestList:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "my_model.sql": my_model_vars_sql,
+            "my_model_a.sql": my_model_a_sql,
+            "my_model_b.sql": my_model_b_sql,
+            "test_my_model.yml": test_my_model_yml + datetime_test,
+        }
+
+    @pytest.fixture(scope="class")
+    def project_config_update(self):
+        return {"vars": {"my_test": "my_test_var"}}
+
+    def test_unit_test_list(self, project):
+        # make sure things are working
+        results = run_dbt(["run"])
+        assert len(results) == 3
+        results = run_dbt(["test"], expect_pass=False)
+        assert len(results) == 5
+
+        results = run_dbt(["list"])
+        expected = [
+            "test.my_model",
+            "test.my_model_a",
+            "test.my_model_b",
+            "unit_test:test.test_my_model",
+            "unit_test:test.test_my_model_datetime",
+            "unit_test:test.test_my_model_empty",
+            "unit_test:test.test_my_model_overrides",
+            "unit_test:test.test_my_model_string_concat",
+        ]
+        assert sorted(results) == sorted(expected)
+
+        results = run_dbt(["list", "--select", "test_type:unit"])
+        assert len(results) == 5
+
+        # Check json result
+        results = run_dbt(["list", "--select", "test_type:unit", "--output", "json"])
+        expected_test_my_model = {
+            "name": "test_my_model",
+            "resource_type": "unit_test",
+            "package_name": "test",
+            "original_file_path": os.path.join("models", "test_my_model.yml"),
+            "unique_id": "unit_test.test.my_model.test_my_model",
+            "depends_on": {"macros": [], "nodes": ["model.test.my_model"]},
+            "config": {"tags": [], "meta": {}},
+        }
+        for result in results:
+            json_result = json.loads(result)
+            if "name" in json_result and json_result["name"] == "test_my_model":
+                assert json_result == expected_test_my_model
+
+        results = run_dbt(
+            [
+                "list",
+                "--select",
+                "test_type:unit",
+                "--output",
+                "json",
+                "--output-keys",
+                "unique_id",
+                "model",
+            ]
+        )
+        for result in results:
+            json_result = json.loads(result)
+            assert json_result["model"] == "my_model"