Repository: dbt-labs/dbt-core

Issue:
[CT-3291] [Feature] unit test models that depend on external nodes
### Is this your first time submitting a feature request?

- [X] I have read the [expectations for open source contributors](https://docs.getdbt.com/docs/contributing/oss-expectations)
- [X] I have searched the existing issues, and I could not find an existing issue for this feature
- [X] I am requesting a straightforward extension of existing dbt functionality, rather than a Big Idea better suited to a discussion

### Describe the feature

You should be able to unit test models that depend on external nodes!
```
unit_tests:
  - name: test_is_valid_email_address # this is the unique name of the test
    model: my_model # name of the model I'm unit testing
    given: 
      - input: ref('dougs_project', 'users')
        rows:
         - {user_id: 1, email: cool@example.com,     email_top_level_domain: example.com}
         - {user_id: 2, email: cool@unknown.com,     email_top_level_domain: unknown.com}
         - {user_id: 3, email: badgmail.com,         email_top_level_domain: gmail.com}
         - {user_id: 4, email: missingdot@gmailcom,  email_top_level_domain: gmail.com}
      - input: ref('top_level_domains')
        rows:
         - {tld: example.com}
         - {tld: gmail.com}
    expect: # required: the expected output given the inputs above
      - {user_id: 1, is_valid_email_address: true}
      - {user_id: 2, is_valid_email_address: false}
      - {user_id: 3, is_valid_email_address: false}
      - {user_id: 4, is_valid_email_address: false}
```

### Describe alternatives you've considered

_No response_

### Who will this benefit?

_No response_

### Are you interested in contributing this feature?

_No response_

### Anything else?

_No response_


Discussion:
This could be tested similar to what's done for [testing partial parsing with external nodes here](https://github.com/dbt-labs/dbt-core/blob/main/tests/functional/partial_parsing/test_partial_parsing.py#L703)
Notes from estimation:
- This may already work, but we should add a test and add more defense code for how we're handling these node attributes

Resolving patch:
diff --git a/core/dbt/context/providers.py b/core/dbt/context/providers.py
--- a/core/dbt/context/providers.py
+++ b/core/dbt/context/providers.py
@@ -67,7 +67,6 @@
     MacroResultAlreadyLoadedError,
     MetricArgsError,
     OperationsCannotRefEphemeralNodesError,
-    PackageNotInDepsError,
     ParsingError,
     RefBadContextError,
     RefArgsError,
@@ -675,10 +674,8 @@ def packages_for_node(self) -> Iterable[Project]:
         package_name = self._node.package_name
 
         if package_name != self._config.project_name:
-            if package_name not in dependencies:
-                # I don't think this is actually reachable
-                raise PackageNotInDepsError(package_name, node=self._node)
-            yield dependencies[package_name]
+            if package_name in dependencies:
+                yield dependencies[package_name]
         yield self._config
 
     def _generate_merged(self) -> Mapping[str, Any]:


Test patch:
diff --git a/core/dbt/parser/unit_tests.py b/core/dbt/parser/unit_tests.py
--- a/core/dbt/parser/unit_tests.py
+++ b/core/dbt/parser/unit_tests.py
@@ -118,7 +118,6 @@ def parse_unit_test_case(self, test_case: UnitTestDefinition):
 
             common_fields = {
                 "resource_type": NodeType.Model,
-                "package_name": test_case.package_name,
                 "original_file_path": original_input_node.original_file_path,
                 "config": ModelConfig(materialized="ephemeral"),
                 "database": original_input_node.database,
@@ -137,9 +136,10 @@ def parse_unit_test_case(self, test_case: UnitTestDefinition):
                 input_name = original_input_node.name
                 input_node = ModelNode(
                     **common_fields,
-                    unique_id=f"model.{test_case.package_name}.{input_name}",
+                    package_name=original_input_node.package_name,
+                    unique_id=f"model.{original_input_node.package_name}.{input_name}",
                     name=input_name,
-                    path=original_input_node.path,
+                    path=original_input_node.path or f"{input_name}.sql",
                 )
             elif original_input_node.resource_type == NodeType.Source:
                 # We are reusing the database/schema/identifier from the original source,
@@ -148,7 +148,8 @@ def parse_unit_test_case(self, test_case: UnitTestDefinition):
                 input_name = original_input_node.name
                 input_node = UnitTestSourceDefinition(
                     **common_fields,
-                    unique_id=f"model.{test_case.package_name}.{input_name}",
+                    package_name=original_input_node.package_name,
+                    unique_id=f"model.{original_input_node.package_name}.{input_name}",
                     name=original_input_node.name,  # must be the same name for source lookup to work
                     path=input_name + ".sql",  # for writing out compiled_code
                     source_name=original_input_node.source_name,  # needed for source lookup
diff --git a/tests/functional/unit_testing/fixtures.py b/tests/functional/unit_testing/fixtures.py
--- a/tests/functional/unit_testing/fixtures.py
+++ b/tests/functional/unit_testing/fixtures.py
@@ -1,3 +1,5 @@
+import pytest
+
 my_model_vars_sql = """
 SELECT
 a+b as c,
@@ -649,3 +651,72 @@
       rows:
         - {c: 3}
 """
+
+top_level_domains_sql = """
+SELECT 'example.com' AS tld
+UNION ALL
+SELECT 'gmail.com' AS tld
+"""
+
+valid_emails_sql = """
+WITH
+accounts AS (
+  SELECT user_id, email, email_top_level_domain
+  FROM {{ ref('external_package', 'external_model')}}
+),
+top_level_domains AS (
+  SELECT tld FROM {{ ref('top_level_domains')}}
+),
+joined AS (
+  SELECT
+    accounts.user_id as user_id,
+    top_level_domains.tld as tld
+  FROM accounts
+  LEFT OUTER JOIN top_level_domains
+    ON   accounts.email_top_level_domain = top_level_domains.tld
+)
+
+SELECT
+  joined.user_id as user_id,
+  CASE WHEN joined.tld IS NULL THEN FALSE ELSE TRUE END AS is_valid_email_address
+from joined
+"""
+
+external_package__accounts_seed_csv = """user_id,email,email_top_level_domain
+1,"example@example.com","example.com"
+"""
+
+external_package__external_model_sql = """
+SELECT user_id, email, email_top_level_domain FROM {{ ref('accounts_seed') }}
+"""
+
+
+external_package_project_yml = """
+name: external_package
+version: '1.0'
+config-version: 2
+
+model-paths: ["models"]    # paths to models
+analysis-paths: ["analyses"] # path with analysis files which are compiled, but not run
+target-path: "target"      # path for compiled code
+clean-targets: ["target"]  # directories removed by the clean task
+test-paths: ["tests"]       # where to store test results
+seed-paths: ["seeds"]       # load CSVs from this directory with `dbt seed`
+macro-paths: ["macros"]    # where to find macros
+
+profile: user
+
+models:
+    external_package:
+"""
+
+
+@pytest.fixture(scope="class")
+def external_package():
+    return {
+        "dbt_project.yml": external_package_project_yml,
+        "seeds": {"accounts_seed.csv": external_package__accounts_seed_csv},
+        "models": {
+            "external_model.sql": external_package__external_model_sql,
+        },
+    }
diff --git a/tests/functional/unit_testing/test_unit_testing.py b/tests/functional/unit_testing/test_unit_testing.py
--- a/tests/functional/unit_testing/test_unit_testing.py
+++ b/tests/functional/unit_testing/test_unit_testing.py
@@ -1,4 +1,5 @@
 import pytest
+from unittest import mock
 from dbt.tests.util import (
     run_dbt,
     write_file,
@@ -6,7 +7,9 @@
 )
 from dbt.contracts.results import NodeStatus
 from dbt.exceptions import DuplicateResourceNameError, ParsingError
-from fixtures import (
+from dbt.plugins.manifest import PluginNodes, ModelNodeArgs
+from dbt.tests.fixtures.project import write_project_files
+from fixtures import (  # noqa: F401
     my_model_sql,
     my_model_vars_sql,
     my_model_a_sql,
@@ -17,6 +20,10 @@
     event_sql,
     test_my_model_incremental_yml,
     test_my_model_yml_invalid,
+    valid_emails_sql,
+    top_level_domains_sql,
+    external_package__accounts_seed_csv,
+    external_package,
 )
 
 
@@ -256,3 +263,100 @@ def test_invalid_input_configuration(self, project):
         assert len(results) == 3
 
         run_dbt(["test"], expect_pass=False)
+
+
+unit_test_ext_node_yml = """
+unit_tests:
+  - name: unit_test_ext_node
+    model: valid_emails
+    given:
+      - input: ref('external_package', 'external_model')
+        rows:
+          - {user_id: 1, email: cool@example.com,     email_top_level_domain: example.com}
+          - {user_id: 2, email: cool@unknown.com,     email_top_level_domain: unknown.com}
+          - {user_id: 3, email: badgmail.com,         email_top_level_domain: gmail.com}
+          - {user_id: 4, email: missingdot@gmailcom,  email_top_level_domain: gmail.com}
+      - input: ref('top_level_domains')
+        rows:
+          - {tld: example.com}
+          - {tld: gmail.com}
+    expect:
+      rows:
+        - {user_id: 1, is_valid_email_address: true}
+        - {user_id: 2, is_valid_email_address: false}
+        - {user_id: 3, is_valid_email_address: true}
+        - {user_id: 4, is_valid_email_address: true}
+"""
+
+
+class TestUnitTestExternalPackageNode:
+    @pytest.fixture(scope="class", autouse=True)
+    def setUp(self, project_root, external_package):  # noqa: F811
+        write_project_files(project_root, "external_package", external_package)
+
+    @pytest.fixture(scope="class")
+    def packages(self):
+        return {"packages": [{"local": "external_package"}]}
+
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "top_level_domains.sql": top_level_domains_sql,
+            "valid_emails.sql": valid_emails_sql,
+            "unit_test_ext_node.yml": unit_test_ext_node_yml,
+        }
+
+    def test_unit_test_ext_nodes(
+        self,
+        project,
+    ):
+        # `deps` to install the external package
+        run_dbt(["deps"], expect_pass=True)
+        # `seed` need so a table exists for `external_model` to point to
+        run_dbt(["seed"], expect_pass=True)
+        # `run` needed to ensure `top_level_domains` exists in database for column getting step
+        run_dbt(["run"], expect_pass=True)
+        results = run_dbt(["test", "--select", "valid_emails"], expect_pass=True)
+        assert len(results) == 1
+
+
+class TestUnitTestExternalProjectNode:
+    @pytest.fixture(scope="class")
+    def external_model_node(self, unique_schema):
+        return ModelNodeArgs(
+            name="external_model",
+            package_name="external_package",
+            identifier="external_node_seed",
+            schema=unique_schema,
+        )
+
+    @pytest.fixture(scope="class")
+    def seeds(self):
+        return {"external_node_seed.csv": external_package__accounts_seed_csv}
+
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "top_level_domains.sql": top_level_domains_sql,
+            "valid_emails.sql": valid_emails_sql,
+            "unit_test_ext_node.yml": unit_test_ext_node_yml,
+        }
+
+    @mock.patch("dbt.plugins.get_plugin_manager")
+    def test_unit_test_ext_nodes(
+        self,
+        get_plugin_manager,
+        project,
+        external_model_node,
+    ):
+        # initial plugin - one external model
+        external_nodes = PluginNodes()
+        external_nodes.add_model(external_model_node)
+        get_plugin_manager.return_value.get_nodes.return_value = external_nodes
+
+        # `seed` need so a table exists for `external_model` to point to
+        run_dbt(["seed"], expect_pass=True)
+        # `run` needed to ensure `top_level_domains` exists in database for column getting step
+        run_dbt(["run"], expect_pass=True)
+        results = run_dbt(["test", "--select", "valid_emails"], expect_pass=True)
+        assert len(results) == 1