Skip to content

Refactor the test suite - #1766

Open
maximlt wants to merge 33 commits into
mainfrom
refactor-tests-v2
Open

maximlt wants to merge 33 commits into
mainfrom
refactor-tests-v2

Conversation

@maximlt

@maximlt maximlt commented Oct 26, 2026

Copy link
Copy Markdown
Member

Rework of hvplot/tests. Goals: make it obvious where a test goes, test every plot option and kind, cover the data and plotting backends where relevant, isolate the tests, and keep the default run fast and offline.

The issues found along the way are listed in a comment below and will be addressed separately.

What changed

  • Layout: kinds/ (one module per kind, plus test_all_kinds.py sweeping every kind on each backend), options/ (one module per option group), plotting/ (pandas backend and the hvplot.plotting functions), data_libraries/ (accessors, and the parity test checking that each tabular library gives the same plot as pandas). Inventory tests fail when a new option or kind has no test.
  • Plain pytest: no more TestCase classes or parameterized.
  • Isolation: each test starts from a plain HoloViews state and unpatches the accessors afterwards, so tests run in random order (pytest-randomly) and in parallel (xdist). GeoViews' state only applies to tests marked geo.
  • Rendering: every plot returned by hvPlot is rendered by default, since many bugs only show up at render. --no-render turns it off.
  • Data libraries and backends: the tabular_lib, gridded_lib and backend fixtures run a test across libraries or plotting backends. Known per-library failures use lib_xfail, lib_render_xfail and lib_skip. The library lists live in _deps.py.
  • Optional dependencies: the paths with and without an optional dependency are both tested (block_import fixture).
  • Speed: the Datashader tests are marked slow and skipped by default, --slow runs them.
  • Offline: pytest-socket blocks the network. scripts/download_data.py fetches the Natural Earth data the geo tests render.
  • Known bugs: hvPlot bugs are strict xfails with a BUG: reason; upstream failures (HoloViews, dask, datashader) are non-strict xfails.
  • Guide: the developer guide has a shorter "Writing unit tests" section.

Outside the tests:

  • private _unpatch() added to each data library extension module, used by the test isolation;
  • the 'violine' typo in _kind_options fixed to 'violin';
  • cartopy <0.26 pinned in the py311 test env (GeoViews 1.15 fails to render with it);
  • CI: no separate geo step, since the geo tests now run with the unit tests; CI runs the slow tests with coverage; the pip job notebook command runs on doc only, like the pixi task.

Remove unittest.TestCase, ComparisonTestCase and parameterized; turn the
dask subclasses into tabular_lib/gridded_lib parametrization; deduplicate
tests; fix test bugs; replace import guards with requires(); mark hvPlot
bugs found as strict xfails.
…tion

options/test_options_inventory.py and kinds/test_kinds_inventory.py check
that every option of HoloViewsConverter._options_groups and every kind
option of _kind_options has a test named after it in the matching module.
hvPlot bugs uncovered by the new tests are marked as strict xfails.
…kinds

Address the review: compare dim expressions by repr, test non-default
option values, restore dask variants and whole-element comparisons lost
in the migration, fix xfail/skip reasons, turn leftover classes into
functions, and test the y container types on pandas only so that
test_all_kinds goes from 690 to 169 tests while really checking y.
…render

hvPlot configures HoloViews objects, but an invalid configuration often
only fails when the plot is built. The test suite now renders every
HoloViews object returned by hvPlot with the current backend. Failures
are triaged: test data fixed, hvPlot bugs and upstream bugs marked with
the new render_xfail marker, which only applies when rendering.
Following the review of how deeply each option was tested, add tests for
the untested forms, values, data types and backends of the options and
kinds (e.g. all legend positions, color/cmap forms, by/groupby lists and
gridded data, ylim/xlim/invert per kind, hist/kde by, hexbin effects),
and strict BUG xfails for the hvPlot bugs they uncovered. Use lighter
Natural Earth scales in the geo tests.
@maximlt

maximlt commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

The AI expanded the test suite at my request and found quite a number of issues. I'm posting them for the record for now.

Issues found while refactoring the test suite

To be addressed in separate PRs. Paths are relative to hvplot/tests/.

  • hvPlot bugs have a strict xfail whose reason starts with BUG: (grep -rn "BUG:" hvplot/tests), so fixing one makes its test XPASS and fail: remove the xfail with the fix.
  • Upstream failures have a non-strict xfail whose reason names the library (HoloViews:, dask:, datashader:).
  • A render_xfail only applies when the plot is rendered (the default; --no-render skips rendering).

hvPlot bugs

Options

  • row lays the facets out horizontally, and row/col are swapped in the grid (grid = [row, col] but the first kdim of GridSpace is x). options/test_data.py::test_row_layout, test_row_and_col_layout
  • fields renaming x or y breaks groupby (and row/col). options/test_data.py::test_fields_with_groupby
  • Defaults set with hv.opts.defaults are only read for bokeh, converter.py hard-codes backend='bokeh'. options/test_axis.py::test_logx_from_holoviews_defaults
  • cticks is dropped on matplotlib with compatibility='bokeh', the style key is chosen from _backend instead of _backend_compat. options/test_axis.py
  • scatter(c=col, clim=...) ignores clim, points honors it. options/test_color.py::test_clim_scatter_c_column
  • check_symmetric_max is ignored for tabular data, only used for xarray. options/test_color.py::test_check_symmetric_max_tabular
  • hover_cols on an xarray Dataset (image, quadmesh) raises; 'all' raises or duplicates vdims. options/test_interactivity.py::test_hover_cols_gridded
  • hover='vline' is dropped when 'hover' is also in tools. options/test_interactivity.py::test_hover_line_mode_with_hover_tool
  • features=['foo'] raises AttributeError instead of the intended ValueError. options/test_geographic.py::test_features_invalid_name_error
  • The invalid coastline scale warning is missing its f prefix. options/test_geographic.py::test_coastline_invalid_scale_warning
  • Hierarchical columns are not auto stacked (Support for Pandas DataFrames with hierarchical columns broken? #1364, pre-existing xfail). test_converter.py::test_hierarchical_columns_auto_stack

Kinds

  • labels:
    • c is not applied to the text color, xoffset and yoffset are dropped. kinds/test_labels.py::test_c, test_xoffset, test_yoffset
    • with the default compatibility every labels plot fails to render on matplotlib, Axes.text() got multiple values for argument 's' (converter.py). kinds/test_labels.py, kinds/test_all_kinds.py
    • on plotly, size is read as a marker size and square-rooted, so the font size can't be set. kinds/test_labels.py::test_text_font_size[plotly-...]
  • points, paths, polygons ignore a lone x or y and plot the first two columns. test_x_or_y_only in kinds/test_points.py, test_paths.py, test_polygons.py
  • points defaults x/y to the first two columns, not the first numeric ones as documented. kinds/test_points.py::test_x_default_first_numeric
  • Gridded paths/polygons without x/y get a single kdim (variables[:2:-1] is an empty slice). test_gridded_default in kinds/test_paths.py, test_polygons.py
  • df.hvplot.heatmap(x, y) without C raises, the method always passes C=None; kind='heatmap' works. kinds/test_heatmap.py::test_C_default
  • rgb with x/y swapped doesn't transpose the data and raises a DataError. kinds/test_rgb.py::test_x_swapped
  • trimesh ignores x/y and raises a DataError when one is set. kinds/test_trimesh.py::test_x_node_coordinate, test_y_node_coordinate
  • dataset(columns=...) keeps all the columns, columns is only used by a dead branch. kinds/test_dataset.py
  • kind='dataset' raises KeyError: 'Dataset' on matplotlib and plotly (_transfer_opts, backend_transforms.py), the Dataset should simply be returned. kinds/test_all_kinds.py
  • errorbars without yerr1 (optional per the docstring) builds an ErrorBars with no error dimension, KeyError at render. kinds/test_all_kinds.py
  • bivariate is not rejected on plotly, it fails with KeyError: 'Density' instead of a NotImplementedError. kinds/test_bivariate.py, kinds/test_all_kinds.py
  • plotly: alpha (scalar or column) is dropped on points even with the default compatibility (scatter keeps it). kinds/test_points.py::test_alpha_dim[plotly], test_alpha_dim_overlay[plotly]
  • plotly with compatibility='bokeh': alpha is renamed to opacity, which plotly elements ignore. test_backend_transforms.py::test_bokeh_options_translated

Data libraries

Most of these are found by data_libraries/test_tabular_parity.py, which checks that the same call gives the same plot with pandas and with each tabular library. Its cases are test_same_plot_as_pandas[<lib>-<case>], and the Datashader ones are test_same_plot_as_pandas_datashader[<lib>-<case>] (slow).

polars and duckdb

  • The column selection drops columns referenced only indirectly: a label template text='{cat} {z}' (KeyError), derived datetimes x='time.hour', by='time.hour' and groupby='time.hour', aggregator=ds.mean('z') and selector=('max', 'z') (DataError), color=hv.dim('z') (silently not applied). Cases labels_text_template, heatmap_derived_datetime, line_by_derived_datetime, scatter_groupby_derived_datetime, rasterize_aggregator_column, rasterize_selector_tuple, scatter_color_dim
  • polars 1 and duckdb: table() without columns shows only the numeric columns, pandas shows them all. With only string columns, duckdb raises "None of the columns matched" and polars 1 keeps only the first column. Cases table, table_strings_only
  • polars 2: pl.NUMERIC_DTYPES (deprecated in polars 1.0, removed in 2.0) is used when y isn't given (plotting/core.py), so the default y and table(columns=...) fail. kinds/test_dataset.py, kinds/test_table.py, parity cases line_default_y, table*. Only xfailed with polars 2.
  • duckdb: hover_cols='all' raises UnboundLocalError (plotting/core.py), with or without the index. Cases scatter_hover_cols_all, scatter_hover_cols_all_no_index

ibis

  • A color column (c=<column>, color=hv.dim(...)) reads .dtype of an ibis column, so c with clim, robust, symmetric data, a cmap dict, and rasterize with c all fail. Parity cases scatter_c_*, scatter_color_dim, rasterize_c, rasterize_aggregator_str_c
  • ohlc reads .dtype of an ibis column. kinds/test_ohlc.py, kinds/test_all_kinds.py
  • box, violin, bar and barh fail to melt the table, even with an explicit x. kinds/test_all_kinds.py
  • kde and density require x, which they don't use. kinds/test_kde.py, kinds/test_density.py, kinds/test_all_kinds.py
  • table reads .index. kinds/test_table.py, kinds/test_all_kinds.py, parity cases table*
  • The default y includes the string columns. Case line_default_y
  • A label template fails on .copy. Case labels_text_template
  • Derived datetimes are not supported. Cases *_derived_datetime
  • hist(logx=True) raises TypeError: '>' not supported between instances of 'LazyFrame' and 'int'. Case hist_logx
  • heatmap(C=..., reduce_function=np.sum) fails. Case heatmap_C_reduce_function
  • rasterize with selector=('max', 'z') fails, the selector column is not a dimension of the ibis data. Case rasterize_selector_tuple

dask

  • _process_symmetric raises a TypeError that is swallowed (converter.py), so symmetric, robust (clim_percentile) and the default cmap are ignored: symmetric data gets kbc_r instead of coolwarm. options/test_color.py::test_symmetric_deduced_tabular_lib, test_robust_tabular_lib, parity cases scatter_c_*, scatter_color_dim, rasterize_c, rasterize_aggregator_str_c
  • sort_date is only applied to pandas data (also ibis). Case line_sort_date
  • dt accessor columns (by='date.month') fail with an internal "Mismatched divisions" AssertionError. options/test_data.py::test_by_datetime_accessor[dask]
  • heatmap of the index and columns fails (unknown shape). kinds/test_heatmap.py::test_x_y_default_columns_and_index

Others

  • explorer: fails with polars (Supplied data type DataFrame not understood, ui.py), duckdb and ibis (xlim reads .dtype). test_ui.py::test_explorer_tabular_lib
  • geopandas: with pandas 3, a missing geometry has the geom type NaN, not None, so it's kept and crashes on 'Multi' in gt. kinds/test_polygons.py::test_geometry_none_geodataframe
  • networkx: nodelist is used as positions, not node ids; draw_networkx always hides the axes (if '_axis_defaults':). plotting/test_networkx.py
  • xarray interactive: the isel slider ends at len(dim), one past the last index. test_interactive.py::test_xarray_interactive_isel_slider_end
  • interactive: check_library expects the object to have __module__, not true for a float (pre-existing xfail). test_interactive.py

Behaviors to decide on

Tested as they are, not xfailed. Each could be a bug or intended.

  • box/violin with several y and by silently drop by.
  • projection=True raises although the docstring lists bool (only False has an effect).
  • hist with by, subplots and shared_axes=False still shares the bins (global bin range).
  • rot=0 is ignored (falsy check in converter.py).
  • downsample=False gives the same plot as the default.
  • hexbin with C and no reduce_function returns counts, not C.
  • df.hvplot(kind='box') and the pandas backend df.plot.box() keep hover on, while .hvplot.box() disables it. With hover, a BoxWhisker without kdims fails to render (HoloViews bug below).
  • dataset, paths, points, polygons and table ignore a list y (e.g. y=['A', 'C']).
  • invert=True on barh keeps invert_axes=True.
  • colorbar defaults to True for a numeric c scatter, undocumented.
  • An interactive driven by a function has dim('*') as transform (test_interactive.py::test_interactive_xarray_function).
  • marker mapped to a column: matplotlib and plotly can't render it (HoloViews limitation), hvPlot neither validates nor documents it. test_marker_dim is bokeh only.
  • matplotlib: a dim mapped to alpha is accepted by hvPlot but refused by HoloViews at render. hvPlot could raise early.
  • Unsupported combinations (tiles on matplotlib, bivariate and networkx on plotly) raise unclear KeyErrors.
  • hvplot.extension('bokeh', compatibility='matplotlib') warns that it defaults to bokeh but keeps the matplotlib option names, then fails with KeyError 'matplotlib' later in a fresh process.
  • The explorer locks the geo controls and labels them "require GeoViews" when GeoViews is installed but no geo option is passed.
  • backlog only applies to streamz, which is removed in HoloViews >= 1.23: its test always skips.
  • ibis tables have no index: kinds needing the index for x raise "Set x explicitly", and hover_cols='all' has no index column.
  • HoloViews groups dask (and polars) data in a different order than pandas (b, a instead of a, b), so colors and legend order differ. ibis row order is nondeterministic. The parity test ignores the key order.
  • tiles=True does not project the lat/lon of dask and ibis data (documented in the parity test SKIPS).
  • xarray.py creates widgets with name=, deprecated in Panel.

Docstring issues

  • barh: x/y descriptions are swapped.
  • heatmap: the C default is not a "simple count" but the first remaining column; reduce_function says duplicates are dropped.
  • contourf returns Polygons, not Contours.
  • kde filled mentions bivariate contours; draw_kamada_kawai says circular layout.
  • trimesh documents x/y but ignores them; points says x/y default to the first numeric fields.
  • labels c, xoffset, yoffset are in _kind_options but undocumented.
  • widget_location lists 'right' twice and doesn't say it returns a Panel layout.
  • frame_width/frame_height, min_*/max_* (Bokeh only) and responsive (bokeh/plotly) don't name their backends.
  • cticks says Bokeh only, but there is a matplotlib branch.
  • xaxis='bare' is used in the docs but not in the docstring.
  • colorbar is silently ignored on Curve; cmap doesn't say by ignores it; robust is documented for image/RGB only but forwarded for tabular data.
  • stream accepts only Pipe/Buffer, the Buffer branch is dead; projection lists bool.
  • paths "gridded data" doesn't say x/y are required.
  • The fontsize dict example ('15pt', '5px') fails on matplotlib.
  • ohlc docs notebook: y=['open','low','high','close'] is called equivalent to the default.

Found but not tested yet

  • hover_cols as a tuple raises AttributeError (converter.py).
  • cube.hvplot.image(rasterize=True, dynamic=False) on a (t, y, x) DataArray fails at render: the static groupby leaves a length-1 t dim, ValueError: ('y', 'x') must be a permuted list of ('t', 'y', 'x'). quadmesh is not affected.
  • 2D (curvilinear) lon/lat coords: quadmesh() takes x/y from the reversed coord order, so x is lat when lon is declared first. da.hvplot() on such data picks image and fails.
  • crs or 'crs' in _process_crs is dead code.
  • The test sweep of the kinds on matplotlib and plotly is pandas only, the gridded kinds (image, quadmesh, contour, contourf, rgb, trimesh) are not swept on those backends.
  • The pip job example tests fail on the xugrid notebook, as on main, because no pip extra installs xugrid.

Upstream issues, not filed yet

Each has a non-strict xfail in the test suite.

# HoloViews, plotly: numeric fontsize
hv.extension('plotly'); df = pd.DataFrame({'x': [1, 2, 3], 'y': [4, 5, 6]})
hv.renderer('plotly').get_plot(hv.Scatter(df, 'x', 'y').opts(fontsize=12))  # TypeError: argument of type 'int' is not iterable

# HoloViews, plotly: xaxis=True (bokeh and matplotlib accept it)
hv.renderer('plotly').get_plot(hv.Scatter(df, 'x', 'y').opts(xaxis=True))  # ValueError: does not accept True

# HoloViews, plotly: Bivariate
hv.renderer('plotly').get_plot(hv.Bivariate(df, ['x', 'y']))  # KeyError: 'Density'

# HoloViews, bokeh: BoxWhisker without kdims and with hover
hv.renderer('bokeh').get_plot(hv.BoxWhisker(pd.DataFrame({'0': [0, 1, 2]}), [], '0').opts(tools=['hover']))  # ValueError: zip() argument 2 is longer than argument 1

# HoloViews, bokeh: Area with string values
hv.renderer('bokeh').get_plot(hv.Area(pd.DataFrame({'upper': list('abc')}), 'index', 'upper'))  # TypeError: '<=' not supported between 'int' and 'str'

# HoloViews, Narwhals interface with an ibis table (t = ibis.memtable(pd.DataFrame(...))), each fails at render:
hv.HexTiles(t, ['x', 'y'])                              # TypeError: Unexpected type for Series.__getitem__ (ndarray)
hv.Violin(t, [], 'y')                                   # same TypeError
hv.BoxWhisker(t, [], 'y')                               # TypeError: Slicing is not supported on LazyFrame
hv.Curve(t, 'x', 'y').opts(interpolation='steps-mid')   # TypeError: Cannot interpret 'Int64' as a data type
hv.Polygons([t], ['x', 'lon'])                          # AttributeError: 'Series' object has no attribute 'astype'
hv.VectorField(t, ['x', 'y'], ['angle', 'mag'])         # ArrowNotImplementedError: add int64 + list<double>
hv.HeatMap(t, ['x', 'y'], 'value')                      # DataError: PandasInterface, DataFrame constructor not properly called

# HoloViews, Bars of a dask DataFrame with two kdims
ddf = dd.from_pandas(pd.DataFrame({'x': [1, 3, 5], 'y': [2, 4, 6], 'category': list('ABC')}), npartitions=2)
hv.Bars(ddf, ['x', 'category'], 'y')  # ValueError: Cannot call len() on object with unknown chunk size

# HoloViews, apply_when + rasterize below the threshold drops the raw points after render
from holoviews.operation import apply_when
from holoviews.operation.datashader import rasterize
df = pd.DataFrame({'x': np.random.random(50), 'y': np.random.random(50)})
dmap = apply_when(hv.Scatter(df, 'x', 'y'), operation=rasterize, predicate=lambda p: len(p) > 1000)
hv.render(dmap)
print([(type(e).__name__, len(e)) for e in dmap.items()[0][1]])  # [('Image', 0), ('Scatter', 0)], expected Scatter(50)

# HoloViews + datashader, rasterize with a selector on dask data
ddf = dd.from_pandas(pd.DataFrame({'x': [1., 2, 3], 'y': [1., 2, 3], 'z': [1., 2, 3]}), npartitions=2)
hv.render(rasterize(hv.Points(ddf, ['x', 'y'], ['z']), selector=ds.first('z')))  # Array chunk size or shape is unknown

# dask 2026.8.0
ddf = dd.from_pandas(pd.DataFrame({'x': [1, 2], 'y': [3, 4]}), npartitions=2)
ddf.rename_axis(columns='My Name')['x'].max().compute()  # TypeError: Series.rename_axis() got an unexpected keyword argument 'columns'

# datashader, pandas 3 string dtype
df = pd.DataFrame({'x': [1., 2, 3], 'y': [1., 2, 3], 'category': list('abc')})
ds.Canvas(plot_width=10, plot_height=10).points(df, 'x', 'y', ds.first('category'))  # TypeError: Cannot interpret '<StringDtype(na_value=nan)>' as a data type

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant