Repository: narwhals-dev/narwhals
Pull request #482 (2024-07-10 13:43:33)

clean: (partial) factor `test_common`
## What type of PR is this? (check all applicable)

- [x] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [x] ✅ Test
- [ ] 🐳 Other

## Related issues 

- Related issue #401 

## Checklist

- [x] Code follows style guide (ruff)
- [ ] Tests added 
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below.

Unsure how to handle `to_pandas` and `to_numpy` failures



Patch:
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 0f32c5c8..88bd32c3 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
 
 
 class ArrowExpr:
-    def __init__(  # noqa: PLR0913
+    def __init__(
         self,
         call: Callable[[ArrowDataFrame], list[ArrowSeries]],
         *,
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index c7c94973..36af9f1f 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -38,7 +38,7 @@ class ArrowNamespace:
     Duration = dtypes.Duration
     Date = dtypes.Date
 
-    def _create_expr_from_callable(  # noqa: PLR0913
+    def _create_expr_from_callable(
         self,
         func: Callable[[ArrowDataFrame], list[ArrowSeries]],
         *,
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index dba3dd91..a889885c 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -305,8 +305,8 @@ class PandasLikeDataFrame:
         other: Self,
         *,
         how: Literal["left", "inner", "outer", "cross", "anti", "semi"] = "inner",
-        left_on: str | list[str] | None = None,
-        right_on: str | list[str] | None = None,
+        left_on: str | list[str] | None,
+        right_on: str | list[str] | None,
     ) -> Self:
         if isinstance(left_on, str):
             left_on = [left_on]
@@ -384,6 +384,23 @@ class PandasLikeDataFrame:
                 )
             )
 
+        if how == "left":
+            other_native = other._native_dataframe
+            result_native = self._native_dataframe.merge(
+                other_native,
+                how="left",
+                left_on=left_on,
+                right_on=right_on,
+                suffixes=("", "_right"),
+            )
+            extra = []
+            for left_key, right_key in zip(left_on, right_on):  # type: ignore[arg-type]
+                if right_key != left_key and right_key not in self.columns:
+                    extra.append(right_key)
+                elif right_key != left_key:
+                    extra.append(f"{right_key}_right")
+            return self._from_native_dataframe(result_native.drop(columns=extra))
+
         return self._from_native_dataframe(
             self._native_dataframe.merge(
                 other._native_dataframe,
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index d507730f..30719f26 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -18,7 +18,7 @@ if TYPE_CHECKING:
 
 
 class PandasLikeExpr:
-    def __init__(  # noqa: PLR0913
+    def __init__(
         self,
         call: Callable[[PandasLikeDataFrame], list[PandasLikeSeries]],
         *,
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index 864268e7..f5511785 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -91,7 +91,7 @@ class PandasLikeGroupBy:
         )
 
 
-def agg_pandas(  # noqa: PLR0913
+def agg_pandas(
     grouped: Any,
     exprs: list[PandasLikeExpr],
     keys: list[str],
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 6f54423f..da237aa0 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -56,7 +56,7 @@ class PandasLikeNamespace:
         self._implementation = implementation
         self._backend_version = backend_version
 
-    def _create_expr_from_callable(  # noqa: PLR0913
+    def _create_expr_from_callable(
         self,
         func: Callable[[PandasLikeDataFrame], list[PandasLikeSeries]],
         *,
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index b14d4c86..7b8cf0d7 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -131,7 +131,7 @@ class PandasLikeSeries:
         )
 
     @classmethod
-    def _from_iterable(  # noqa: PLR0913
+    def _from_iterable(
         cls: type[Self],
         data: Iterable[Any],
         name: str,
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index b78d04ec..e255844a 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -174,11 +174,11 @@ class BaseFrame(Generic[FrameT]):
         self,
         other: Self,
         *,
-        how: Literal["inner", "cross", "semi", "anti"] = "inner",
+        how: Literal["inner", "left", "cross", "semi", "anti"] = "inner",
         left_on: str | list[str] | None = None,
         right_on: str | list[str] | None = None,
     ) -> Self:
-        _supported_joins = ("inner", "cross", "anti", "semi")
+        _supported_joins = ("inner", "left", "cross", "anti", "semi")
 
         if how not in _supported_joins:
             msg = f"Only the following join stragies are supported: {_supported_joins}; found '{how}'."
@@ -1569,7 +1569,7 @@ class DataFrame(BaseFrame[FrameT]):
         self,
         other: Self,
         *,
-        how: Literal["inner", "cross", "semi", "anti"] = "inner",
+        how: Literal["inner", "left", "cross", "semi", "anti"] = "inner",
         left_on: str | list[str] | None = None,
         right_on: str | list[str] | None = None,
     ) -> Self:
@@ -2995,7 +2995,7 @@ class LazyFrame(BaseFrame[FrameT]):
         self,
         other: Self,
         *,
-        how: Literal["inner", "cross", "semi", "anti"] = "inner",
+        how: Literal["inner", "left", "cross", "semi", "anti"] = "inner",
         left_on: str | list[str] | None = None,
         right_on: str | list[str] | None = None,
     ) -> Self:
diff --git a/pyproject.toml b/pyproject.toml
index 60b09c7f..71bd9616 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -58,6 +58,7 @@ lint.ignore = [
   'PD901',  # This is a auxiliary library so dataframe variables have no concrete business meaning
   'PLR0911',
   'PLR0912',
+  'PLR0913',
   'PLR2004',
   'PTH',
   'RET505',


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index c7d2a78b..1453a054 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -36,7 +36,7 @@ def pandas_constructor(obj: Any) -> IntoDataFrame:
 
 
 def pandas_nullable_constructor(obj: Any) -> IntoDataFrame:
-    return pd.DataFrame(obj).convert_dtypes()  # type: ignore[no-any-return]
+    return pd.DataFrame(obj).convert_dtypes(dtype_backend="numpy_nullable")  # type: ignore[no-any-return]
 
 
 def pandas_pyarrow_constructor(obj: Any) -> IntoDataFrame:
diff --git a/tests/frame/head_test.py b/tests/frame/head_test.py
new file mode 100644
index 00000000..ef61334b
--- /dev/null
+++ b/tests/frame/head_test.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from typing import Any
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+def test_head(constructor_with_lazy: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor_with_lazy(data)
+    df = nw.from_native(df_raw).lazy()
+    result = nw.to_native(df.head(2))
+    expected = {"a": [1, 3], "b": [4, 4], "z": [7.0, 8.0]}
+    compare_dicts(result, expected)
+    result = nw.to_native(df.collect().head(2))
+    expected = {"a": [1, 3], "b": [4, 4], "z": [7.0, 8.0]}
+    compare_dicts(result, expected)
+    result = nw.to_native(df.collect().select(nw.col("a").head(2)))
+    expected = {"a": [1, 3]}
+    compare_dicts(result, expected)
diff --git a/tests/frame/is_duplicated_test.py b/tests/frame/is_duplicated_test.py
new file mode 100644
index 00000000..041d6841
--- /dev/null
+++ b/tests/frame/is_duplicated_test.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+
+import narwhals.stable.v1 as nw
+
+
+def test_is_duplicated(constructor: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor(data)
+    df = nw.from_native(df_raw, eager_only=True)
+    result = nw.concat([df, df.head(1)]).is_duplicated()
+    expected = np.array([True, False, False, True])
+    assert (result.to_numpy() == expected).all()
diff --git a/tests/frame/is_empty_test.py b/tests/frame/is_empty_test.py
new file mode 100644
index 00000000..06defe73
--- /dev/null
+++ b/tests/frame/is_empty_test.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+import narwhals.stable.v1 as nw
+
+
+@pytest.mark.parametrize(("threshold", "expected"), [(0, False), (10, True)])
+def test_is_empty(constructor: Any, threshold: Any, expected: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor(data)
+    df = nw.from_native(df_raw, eager_only=True)
+    result = df.filter(nw.col("a") > threshold).is_empty()
+    assert result == expected
diff --git a/tests/frame/is_unique_test.py b/tests/frame/is_unique_test.py
new file mode 100644
index 00000000..2f41b672
--- /dev/null
+++ b/tests/frame/is_unique_test.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+
+import narwhals.stable.v1 as nw
+
+
+def test_is_unique(constructor: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor(data)
+    df = nw.from_native(df_raw, eager_only=True)
+    result = nw.concat([df, df.head(1)]).is_unique()
+    expected = np.array([False, True, True, False])
+    assert (result.to_numpy() == expected).all()
diff --git a/tests/frame/join_test.py b/tests/frame/join_test.py
index a8339277..de91f5b2 100644
--- a/tests/frame/join_test.py
+++ b/tests/frame/join_test.py
@@ -96,7 +96,7 @@ def test_semi_join(
     compare_dicts(result, expected)
 
 
-@pytest.mark.parametrize("how", ["left", "right", "full"])
+@pytest.mark.parametrize("how", ["right", "full"])
 def test_join_not_implemented(constructor_with_lazy: Any, how: str) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
     df = nw.from_native(constructor_with_lazy(data))
@@ -104,7 +104,46 @@ def test_join_not_implemented(constructor_with_lazy: Any, how: str) -> None:
     with pytest.raises(
         NotImplementedError,
         match=re.escape(
-            f"Only the following join stragies are supported: ('inner', 'cross', 'anti', 'semi'); found '{how}'."
+            f"Only the following join stragies are supported: ('inner', 'left', 'cross', 'anti', 'semi'); found '{how}'."
         ),
     ):
         df.join(df, left_on="a", right_on="a", how=how)  # type: ignore[arg-type]
+
+
+@pytest.mark.filterwarnings("ignore:the default coalesce behavior")
+def test_left_join(constructor: Any) -> None:
+    data_left = {"a": [1, 2, 3], "b": [4, 5, 6]}
+    data_right = {"a": [1, 2, 3], "c": [4, 5, 6]}
+    df_left = nw.from_native(constructor(data_left), eager_only=True)
+    df_right = nw.from_native(constructor(data_right), eager_only=True)
+    result = df_left.join(df_right, left_on="b", right_on="c", how="left")
+    expected = {"a": [1, 2, 3], "b": [4, 5, 6], "a_right": [1, 2, 3]}
+    compare_dicts(result, expected)
+
+
+@pytest.mark.filterwarnings("ignore: the defaultcoalesce behavior")
+def test_left_join_multiple_column(constructor: Any) -> None:
+    data_left = {"a": [1, 2, 3], "b": [4, 5, 6]}
+    data_right = {"a": [1, 2, 3], "c": [4, 5, 6]}
+    df_left = nw.from_native(constructor(data_left), eager_only=True)
+    df_right = nw.from_native(constructor(data_right), eager_only=True)
+    result = df_left.join(df_right, left_on=["a", "b"], right_on=["a", "c"], how="left")
+    expected = {"a": [1, 2, 3], "b": [4, 5, 6]}
+    compare_dicts(result, expected)
+
+
+@pytest.mark.filterwarnings("ignore: the defaultcoalesce behavior")
+def test_left_join_overlapping_column(constructor: Any) -> None:
+    data_left = {"a": [1, 2, 3], "b": [4, 5, 6], "d": [1, 4, 2]}
+    data_right = {"a": [1, 2, 3], "c": [4, 5, 6], "d": [1, 4, 2]}
+    df_left = nw.from_native(constructor(data_left), eager_only=True)
+    df_right = nw.from_native(constructor(data_right), eager_only=True)
+    result = df_left.join(df_right, left_on="b", right_on="c", how="left")
+    expected = {
+        "a": [1, 2, 3],
+        "b": [4, 5, 6],
+        "d": [1, 4, 2],
+        "a_right": [1, 2, 3],
+        "d_right": [1, 4, 2],
+    }
+    compare_dicts(result, expected)
diff --git a/tests/frame/lit_test.py b/tests/frame/lit_test.py
new file mode 100644
index 00000000..1a79b344
--- /dev/null
+++ b/tests/frame/lit_test.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Any
+
+import numpy as np
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+if TYPE_CHECKING:
+    from narwhals.dtypes import DType
+
+
+@pytest.mark.parametrize(
+    ("dtype", "expected_lit"),
+    [(None, [2, 2, 2]), (nw.String, ["2", "2", "2"]), (nw.Float32, [2.0, 2.0, 2.0])],
+)
+def test_lit(
+    constructor_with_lazy: Any, dtype: DType | None, expected_lit: list[Any]
+) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor_with_lazy(data)
+    df = nw.from_native(df_raw)
+    result = df.with_columns(nw.lit(2, dtype).alias("lit"))
+    result_native = nw.to_native(result)
+    expected = {
+        "a": [1, 3, 2],
+        "b": [4, 4, 6],
+        "z": [7.0, 8.0, 9.0],
+        "lit": expected_lit,
+    }
+    compare_dicts(result_native, expected)
+
+
+def test_lit_error(constructor_with_lazy: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor_with_lazy(data)
+    df = nw.from_native(df_raw)
+    with pytest.raises(
+        ValueError, match="numpy arrays are not supported as literal values"
+    ):
+        _ = df.with_columns(nw.lit(np.array([1, 2])).alias("lit"))
+    with pytest.raises(
+        NotImplementedError, match="Nested datatypes are not supported yet."
+    ):
+        _ = df.with_columns(nw.lit((1, 2)).alias("lit"))
+    with pytest.raises(
+        NotImplementedError, match="Nested datatypes are not supported yet."
+    ):
+        _ = df.with_columns(nw.lit([1, 2]).alias("lit"))
diff --git a/tests/frame/null_count_test.py b/tests/frame/null_count_test.py
new file mode 100644
index 00000000..4e12dc61
--- /dev/null
+++ b/tests/frame/null_count_test.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from typing import Any
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+def test_null_count(constructor: Any) -> None:
+    data = {"a": [None, 3, 2], "b": [4, 4, 6], "z": [7.0, None, 9]}
+    df_raw = constructor(data)
+    df = nw.from_native(df_raw, eager_only=True)
+    result = nw.to_native(df.null_count())
+    expected = {"a": [1], "b": [0], "z": [1]}
+    compare_dicts(result, expected)
diff --git a/tests/frame/quantile_test.py b/tests/frame/quantile_test.py
new file mode 100644
index 00000000..c667f92d
--- /dev/null
+++ b/tests/frame/quantile_test.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from typing import Any
+from typing import Literal
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+@pytest.mark.parametrize(
+    ("interpolation", "expected"),
+    [
+        ("lower", {"a": [1.0], "b": [4.0], "z": [7.0]}),
+        ("higher", {"a": [2.0], "b": [4.0], "z": [8.0]}),
+        ("midpoint", {"a": [1.5], "b": [4.0], "z": [7.5]}),
+        ("linear", {"a": [1.6], "b": [4.0], "z": [7.6]}),
+        ("nearest", {"a": [2.0], "b": [4.0], "z": [8.0]}),
+    ],
+)
+@pytest.mark.filterwarnings("ignore:the `interpolation=` argument to percentile")
+def test_quantile(
+    constructor: Any,
+    interpolation: Literal["nearest", "higher", "lower", "midpoint", "linear"],
+    expected: dict[str, list[float]],
+) -> None:
+    q = 0.3
+
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor(data)
+    df = nw.from_native(df_raw)
+    result = nw.to_native(
+        df.select(nw.all().quantile(quantile=q, interpolation=interpolation))
+    )
+    compare_dicts(result, expected)
diff --git a/tests/frame/tail_test.py b/tests/frame/tail_test.py
new file mode 100644
index 00000000..768b1969
--- /dev/null
+++ b/tests/frame/tail_test.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from typing import Any
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+def test_tail(constructor_with_lazy: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor_with_lazy(data)
+    df = nw.from_native(df_raw).lazy()
+    result = nw.to_native(df.tail(2))
+    expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9]}
+    compare_dicts(result, expected)
+    result = nw.to_native(df.collect().tail(2))
+    expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9]}
+    compare_dicts(result, expected)
+    result = nw.to_native(df.collect().select(nw.col("a").tail(2)))
+    expected = {"a": [3, 2]}
+    compare_dicts(result, expected)
diff --git a/tests/frame/test_common.py b/tests/frame/test_common.py
index 3234a64a..69be5e6d 100644
--- a/tests/frame/test_common.py
+++ b/tests/frame/test_common.py
@@ -4,7 +4,6 @@ import re
 import warnings
 from typing import TYPE_CHECKING
 from typing import Any
-from typing import Literal
 
 import numpy as np
 import pandas as pd
@@ -21,8 +20,6 @@ from tests.utils import compare_dicts
 from tests.utils import maybe_get_modin_df
 
 if TYPE_CHECKING:
-    from narwhals.dtypes import DType
-    from narwhals.typing import IntoDataFrame
     from narwhals.typing import IntoFrameT
 
 df_pandas = pd.DataFrame({"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]})
@@ -92,45 +89,6 @@ def test_std(df_raw: Any) -> None:
     compare_dicts(result_native, expected)
 
 
-@pytest.mark.parametrize(
-    "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
-)
-@pytest.mark.parametrize(
-    ("dtype", "expected_lit"),
-    [(None, [2, 2, 2]), (nw.String, ["2", "2", "2"]), (nw.Float32, [2.0, 2.0, 2.0])],
-)
-def test_lit(df_raw: Any, dtype: DType | None, expected_lit: list[Any]) -> None:
-    df = nw.from_native(df_raw)
-    result = df.with_columns(nw.lit(2, dtype).alias("lit"))
-    result_native = nw.to_native(result)
-    expected = {
-        "a": [1, 3, 2],
-        "b": [4, 4, 6],
-        "z": [7.0, 8.0, 9.0],
-        "lit": expected_lit,
-    }
-    compare_dicts(result_native, expected)
-
-
-@pytest.mark.parametrize(
-    "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
-)
-def test_lit_error(df_raw: Any) -> None:
-    df = nw.from_native(df_raw)
-    with pytest.raises(
-        ValueError, match="numpy arrays are not supported as literal values"
-    ):
-        _ = df.with_columns(nw.lit(np.array([1, 2])).alias("lit"))
-    with pytest.raises(
-        NotImplementedError, match="Nested datatypes are not supported yet."
-    ):
-        _ = df.with_columns(nw.lit((1, 2)).alias("lit"))
-    with pytest.raises(
-        NotImplementedError, match="Nested datatypes are not supported yet."
-    ):
-        _ = df.with_columns(nw.lit([1, 2]).alias("lit"))
-
-
 @pytest.mark.parametrize(
     "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
 )
@@ -177,31 +135,6 @@ def test_accepted_dataframes() -> None:
         nw.LazyFrame(array, is_polars=False, backend_version=(1,))
 
 
-@pytest.mark.parametrize("df_raw", [df_polars, df_pandas, df_mpd, df_pa])
-@pytest.mark.filterwarnings("ignore:.*Passing a BlockManager.*:DeprecationWarning")
-@pytest.mark.skipif(
-    parse_version(pd.__version__) < parse_version("2.0.0"),
-    reason="too old for pandas-pyarrow",
-)
-def test_convert_pandas(df_raw: Any) -> None:
-    result = nw.from_native(df_raw).to_pandas()  # type: ignore[union-attr]
-    expected = pd.DataFrame({"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]})
-    pd.testing.assert_frame_equal(result, expected)
-
-
-@pytest.mark.parametrize(
-    "df_raw", [df_polars, df_pandas, df_mpd, df_pandas_nullable, df_pandas_pyarrow]
-)
-def test_convert_numpy(df_raw: Any) -> None:
-    result = nw.from_native(df_raw, eager_only=True).to_numpy()
-    expected = np.array([[1, 3, 2], [4, 4, 6], [7.0, 8, 9]]).T
-    np.testing.assert_array_equal(result, expected)
-    assert result.dtype == "float64"
-    result = nw.from_native(df_raw, eager_only=True).__array__()
-    np.testing.assert_array_equal(result, expected)
-    assert result.dtype == "float64"
-
-
 @pytest.mark.parametrize("df_raw", [df_polars, df_pandas, df_mpd, df_lazy])
 def test_expr_binary(df_raw: Any) -> None:
     result = nw.from_native(df_raw).with_columns(
@@ -296,51 +229,6 @@ def test_expr_na(df_raw: Any) -> None:
     compare_dicts(result_nna, expected)
 
 
-@pytest.mark.parametrize(
-    "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
-)
-def test_head(df_raw: Any) -> None:
-    df = nw.from_native(df_raw).lazy()
-    result = nw.to_native(df.head(2))
-    expected = {"a": [1, 3], "b": [4, 4], "z": [7.0, 8.0]}
-    compare_dicts(result, expected)
-    result = nw.to_native(df.collect().head(2))
-    expected = {"a": [1, 3], "b": [4, 4], "z": [7.0, 8.0]}
-    compare_dicts(result, expected)
-    result = nw.to_native(df.collect().select(nw.col("a").head(2)))
-    expected = {"a": [1, 3]}
-    compare_dicts(result, expected)
-
-
-@pytest.mark.parametrize(
-    "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
-)
-def test_tail(df_raw: Any) -> None:
-    df = nw.from_native(df_raw).lazy()
-    result = nw.to_native(df.tail(2))
-    expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9]}
-    compare_dicts(result, expected)
-    result = nw.to_native(df.collect().tail(2))
-    expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9]}
-    compare_dicts(result, expected)
-    result = nw.to_native(df.collect().select(nw.col("a").tail(2)))
-    expected = {"a": [3, 2]}
-    compare_dicts(result, expected)
-
-
-@pytest.mark.parametrize(
-    "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
-)
-def test_unique(df_raw: Any) -> None:
-    df = nw.from_native(df_raw).lazy()
-    result = nw.to_native(df.unique("b").sort("b"))
-    expected = {"a": [1, 2], "b": [4, 6], "z": [7.0, 9.0]}
-    compare_dicts(result, expected)
-    result = nw.to_native(df.collect().unique("b").sort("b"))
-    expected = {"a": [1, 2], "b": [4, 6], "z": [7.0, 9.0]}
-    compare_dicts(result, expected)
-
-
 @pytest.mark.parametrize("df_raw", [df_pandas_na, df_lazy_na])
 def test_drop_nulls(df_raw: Any) -> None:
     df = nw.from_native(df_raw).lazy()
@@ -473,64 +361,6 @@ def test_library(df_raw: Any, df_raw_right: Any) -> None:
         df_left.join(df_right, left_on=["a"], right_on=["a"], how="inner")
 
 
-@pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
-def test_is_duplicated(df_raw: IntoDataFrame) -> None:
-    df = nw.from_native(df_raw, eager_only=True)
-    result = nw.concat([df, df.head(1)]).is_duplicated()
-    expected = np.array([True, False, False, True])
-    assert (result.to_numpy() == expected).all()
-
-
-@pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
-@pytest.mark.parametrize(("threshold", "expected"), [(0, False), (10, True)])
-def test_is_empty(df_raw: Any, threshold: Any, expected: Any) -> None:
-    df = nw.from_native(df_raw, eager_only=True)
-    result = df.filter(nw.col("a") > threshold).is_empty()
-    assert result == expected
-
-
-@pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
-def test_is_unique(df_raw: Any) -> None:
-    df = nw.from_native(df_raw, eager_only=True)
-    result = nw.concat([df, df.head(1)]).is_unique()
-    expected = np.array([False, True, True, False])
-    assert (result.to_numpy() == expected).all()
-
-
-@pytest.mark.parametrize("df_raw", [df_pandas_na, df_lazy_na.collect()])
-def test_null_count(df_raw: Any) -> None:
-    df = nw.from_native(df_raw, eager_only=True)
-    result = nw.to_native(df.null_count())
-    expected = {"a": [1], "b": [0], "z": [1]}
-    compare_dicts(result, expected)
-
-
-@pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
-@pytest.mark.parametrize(
-    ("interpolation", "expected"),
-    [
-        ("lower", {"a": [1.0], "b": [4.0], "z": [7.0]}),
-        ("higher", {"a": [2.0], "b": [4.0], "z": [8.0]}),
-        ("midpoint", {"a": [1.5], "b": [4.0], "z": [7.5]}),
-        ("linear", {"a": [1.6], "b": [4.0], "z": [7.6]}),
-        ("nearest", {"a": [2.0], "b": [4.0], "z": [8.0]}),
-    ],
-)
-@pytest.mark.filterwarnings("ignore:the `interpolation=` argument to percentile")
-def test_quantile(
-    df_raw: Any,
-    interpolation: Literal["nearest", "higher", "lower", "midpoint", "linear"],
-    expected: dict[str, list[float]],
-) -> None:
-    q = 0.3
-
-    df = nw.from_native(df_raw)
-    result = nw.to_native(
-        df.select(nw.all().quantile(quantile=q, interpolation=interpolation))
-    )
-    compare_dicts(result, expected)
-
-
 @pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
 @pytest.mark.parametrize(
     ("row", "column", "expected"),
diff --git a/tests/frame/to_numpy_test.py b/tests/frame/to_numpy_test.py
new file mode 100644
index 00000000..6f516334
--- /dev/null
+++ b/tests/frame/to_numpy_test.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+
+import narwhals.stable.v1 as nw
+
+
+def test_convert_numpy(constructor: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.1, 8, 9]}
+    df_raw = constructor(data)
+    result = nw.from_native(df_raw, eager_only=True).to_numpy()
+
+    expected = np.array([[1, 3, 2], [4, 4, 6], [7.1, 8, 9]]).T
+    np.testing.assert_array_equal(result, expected)
+    assert result.dtype == "float64"
+
+    result = nw.from_native(df_raw, eager_only=True).__array__()
+    np.testing.assert_array_equal(result, expected)
+    assert result.dtype == "float64"
diff --git a/tests/frame/to_pandas_test.py b/tests/frame/to_pandas_test.py
new file mode 100644
index 00000000..52ee9352
--- /dev/null
+++ b/tests/frame/to_pandas_test.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pandas as pd
+import pytest
+
+import narwhals.stable.v1 as nw
+from narwhals.utils import parse_version
+
+
+@pytest.mark.filterwarnings("ignore:.*Passing a BlockManager.*:DeprecationWarning")
+@pytest.mark.skipif(
+    parse_version(pd.__version__) < parse_version("2.0.0"),
+    reason="too old for pandas-pyarrow",
+)
+def test_convert_pandas(constructor_with_pyarrow: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor_with_pyarrow(data)
+    result = nw.from_native(df_raw).to_pandas()  # type: ignore[union-attr]
+
+    if constructor_with_pyarrow.__name__.startswith("pandas"):
+        expected = constructor_with_pyarrow(data)
+    else:
+        expected = pd.DataFrame(data)
+
+    pd.testing.assert_frame_equal(result, expected)
diff --git a/tests/frame/unique_test.py b/tests/frame/unique_test.py
new file mode 100644
index 00000000..5f3911c8
--- /dev/null
+++ b/tests/frame/unique_test.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from typing import Any
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+def test_unique(constructor_with_lazy: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor_with_lazy(data)
+    df = nw.from_native(df_raw).lazy()
+    result = nw.to_native(df.unique("b").sort("b"))
+    expected = {"a": [1, 2], "b": [4, 6], "z": [7.0, 9.0]}
+    compare_dicts(result, expected)
+    result = nw.to_native(df.collect().unique("b").sort("b"))
+    expected = {"a": [1, 2], "b": [4, 6], "z": [7.0, 9.0]}
+    compare_dicts(result, expected)
diff --git a/tests/hypothesis/test_join.py b/tests/hypothesis/test_join.py
index cf019583..d5db53e3 100644
--- a/tests/hypothesis/test_join.py
+++ b/tests/hypothesis/test_join.py
@@ -3,12 +3,14 @@ from __future__ import annotations
 import pandas as pd
 import polars as pl
 import pytest
+from hypothesis import assume
 from hypothesis import given
 from hypothesis import strategies as st
 from pandas.testing import assert_frame_equal
 
 import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
+from tests.utils import compare_dicts
 
 pl_version = parse_version(pl.__version__)
 pd_version = parse_version(pd.__version__)
@@ -116,3 +118,48 @@ def test_cross_join(  # pragma: no cover
     )
 
     assert_frame_equal(dframe_pd1, dframe_pd2)
+
+
+@given(  # type: ignore[misc]
+    a_left_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    b_left_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    c_left_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    a_right_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    b_right_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    d_right_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    left_key=st.lists(
+        st.sampled_from(["a", "b", "c"]), min_size=1, max_size=3, unique=True
+    ),
+    right_key=st.lists(
+        st.sampled_from(["a", "b", "d"]), min_size=1, max_size=3, unique=True
+    ),
+)
+@pytest.mark.slow()
+def test_left_join(  # pragma: no cover
+    a_left_data: list[int],
+    b_left_data: list[int],
+    c_left_data: list[int],
+    a_right_data: list[int],
+    b_right_data: list[int],
+    d_right_data: list[int],
+    left_key: list[str],
+    right_key: list[str],
+) -> None:
+    assume(len(left_key) == len(right_key))
+    data_left = {"a": a_left_data, "b": b_left_data, "c": c_left_data}
+    data_right = {"a": a_right_data, "b": b_right_data, "d": d_right_data}
+    result_pd = nw.from_native(pd.DataFrame(data_left), eager_only=True).join(
+        nw.from_native(pd.DataFrame(data_right), eager_only=True),
+        how="left",
+        left_on=left_key,
+        right_on=right_key,
+    )
+    result_pl = nw.to_native(
+        nw.from_native(pl.DataFrame(data_left), eager_only=True).join(
+            nw.from_native(pl.DataFrame(data_right), eager_only=True),
+            how="left",
+            left_on=left_key,
+            right_on=right_key,
+        )
+    ).select(pl.all().fill_null(float("nan")))
+    compare_dicts(result_pd.to_dict(as_series=False), result_pl.to_dict(as_series=False))