分析代码的哪些部分要编写单元测试?

时间:2019-08-13 10:04:34

标签: python unit-testing analytics

最后,当我编写分析型Python代码时,当用户通过基于队列的批处理与前端工具进行交互时,该代码将按需运行。

通常,用户在前端工具中设置一些值,这些值作为参数传递给分析代码,他们要么提供数据集,要么从公司提供的整体数据源中选择数据的子集。

通常,每个分析模型在其他分析模型中都位于较大的存储库中,因此每个模型通常都位于其自己的模块中,并且该模块将导出一个功能,该功能是该模型的切入点。这些模型的范围从数分钟的简单模型到非常复杂的基于静态或机器学习的模型,并可能使用数小时的numpy / Pandas / Numba或Dask数据帧的组合。

现在我的问题是,我一直在反复讨论我应该集中精力针对这种类型的代码进行测试的地方。在我职业生涯的早期,我天真地认为每个函数都应该有一个单元测试,因此我的代码将具有一组全面的测试。 我很快意识到这是适得其反的,因为即使很小的性能重构也可能导致撕裂,甚至可能丢弃很多单元测试。如此明显地感觉到,我应该只为每个模型的主要公共功能编写测试,但是,这通常意味着相反的情况,对于某些更复杂的模型,很难深入控制流的边缘案例测试。

那么我的问题是我应该如何正确测试这些分析模型?有人可能会说:“仅测试面向公众的功能,如果您无法通过面向公众的功能来测试边缘案例,那么从技术上讲它们应该是不可用的,因此不需要在那里”。但是,我发现,实际上这是行不通的。

举一个简单的例子,假设特定模型是从出租车数据集中计算出落点的频率矩阵。

import pandas as pd


def _cat(col1, col2):
    cat_col = col1.astype(str).str.cat(col2.astype(str), ', ')
    return cat_col


def _make_points_df(taxi_df):
    pickup_points = _cat(taxi_df["pickup_longitude"], taxi_df["pickup_latitude"])
    dropoff_points = _cat(taxi_df["dropoff_longitude"], taxi_df["dropoff_latitude"])
    points_df = pd.DataFrame({"pickup": pickup_points, "dropoff": dropoff_points})
    return points_df


def _points_df_to_freq_mat(points_df):
    mat_df = points_df.groupby(['pickup', 'dropoff']).size().unstack(fill_value=0)
    return mat_df


def _validate_taxi_df(taxi_df):
    if type(taxi_df) is not pd.DataFrame:
        raise TypeError(f"taxi_df param must be a pandas dataframe, got: {type(taxi_df)}")
    expected_cols = {
        "pickup_longitude",
        "pickup_latitude",
        "dropoff_longitude",
        "dropoff_latitude",
    }
    if set(taxi_df) != expected_cols:
        raise RuntimeError(
            f"Expected the following columns for taxi_df param: {expected_cols}."
            f"Got: {set(taxi_df)}"
        )


def calculate_frequency_matrix(taxi_df, long_round=1, lat_round=1):
    """Calculate a dropoff/pickup frequency matrix which tells you the number of times
    passengers have been picked up and dropped from a given discrete point. The
    resolution of these points is controlled by using the long_round and lat_round params

    Paramaters
    ----------
    taxi_df : pandas.DataFrame
        Dataframe specifying dropoff and pickup long/lat coordinates
    long_round : int
        Number of decimal places to round the dropoff and pickup longitude values to
    lat_round : int
        Number of decimal places to round the dropoff and pickup latitude values to

    Returns
    -------
    pandas.DataFrame
        Dataframe in matrix format of frequency of dropoff/pickup points

    Raises
    ------
    TypeError : If taxi_df is not a pandas DataFrame
    RuntimeError : If taxi_df does not contain correct columns
    """
    _validate_taxi_df(taxi_df)
    taxi_df = taxi_df.copy()
    taxi_df["pickup_longitude"] = taxi_df["pickup_longitude"].round(long_round)
    taxi_df["dropoff_longitude"] = taxi_df["dropoff_longitude"].round(long_round)
    taxi_df["pickup_latitude"] = taxi_df["pickup_latitude"].round(lat_round)
    taxi_df["dropoff_latitude"] = taxi_df["dropoff_latitude"].round(lat_round)

    points_df = _make_points_df(taxi_df)
    mat_df = _points_df_to_freq_mat(points_df)
    return mat_df

采用类似的数据框

        pickup_longitude  pickup_latitude  dropoff_longitude  dropoff_latitude
0         -73.988129        40.732029         -73.990173         40.756680
1         -73.964203        40.679993         -73.959808         40.655403
2         -73.997437        40.737583         -73.986160         40.729523
3         -73.956070        40.771900         -73.986427         40.730469
4         -73.970215        40.761475         -73.961510         40.755890
5         -73.991302        40.749798         -73.980515         40.786549
6         -73.978310        40.741550         -73.952072         40.717003
7         -74.012711        40.701527         -73.986481         40.719509

就文件夹结构而言,此代码位于 analytics/models/taxi_freq/taxi_freq.pyanalytics/models/taxi_freq/__init__.py 文件看起来像

from taxi_freq import calculate_frequency_matrix

显然,以上代码中的私有函数可以拆分为analytics/models/taxi_freq/中的多个实用文件。

共识是只测试calculate_frequency_matrix函数,还是应该测试taxi_freq模块中的“私有”辅助方法和其他实用程序文件/函数?

1 个答案:

答案 0 :(得分:0)

与一般的软件开发一样,在测试中,您始终必须寻找能够代表相互竞争的目标之间(最理想的)折衷的解决方案。总体测试以及单元测试的主要目标之一就是发现错误(请参阅Myers,Badgett,Sandler:软件测试的艺术,或Beizer:软件测试技术,以及其他许多东西)。

在您的项目中,您对此可能会比较放松,但是,如果实施级别的错误逃到以后的开发阶段甚至到现场,则在许多软件项目中可能会造成严重后果。有人说,您的目标应该是增加对代码的信心-这也是对的,但是信心只能是正确进行测试的结果。如果您不进行测试以查找错误,那么在完成测试后,我将对您的代码完全没有信心。

当发现错误是单元测试的主要目标时,那么试图使单元测试套件与实现细节完全独立的尝试可能会导致效率低下的测试套件-也就是说,不适合发现所有错误的测试套件可以找到。不同的实现有不同的潜在错误。如果您不使用单元测试来查找这些错误,那么任何其他测试级别(集成,子系统,系统)绝对不适合系统地查找它们。

例如,考虑实现Fibonacci函数的不同方式:作为迭代或递归函数,作为封闭式表达式(Moivre / Binet)或作为查找表:接口始终相同,可能错误的差异很大,单元测试策略也是如此。将会有一组有用的与实现无关的测试用例,但是仅凭这些就不足以找到特定实现可能的所有错误。

因此,拥有一个有效的测试套件的目标是与另一个目标(即拥有易于维护的测试套件)竞争。但是,此目标以不同的形式出现,并带来不同的后果:您可以要求在实现细节更改时,单元测试套件不受影响。这非常困难,并且IMO将维护友好的测试代码的次要目标置于发现错误的主要目标之上。

Meszaros有一个更加平衡的表述,即“更改代码库的工作应与维护测试套件的工作相称。” (请参阅Meszaros:Principles of Test Automation: Ensure Commensurate Effort)。也就是说,对SUT的少量更改只需要对测试套件进行很小的更改,对于SUT的较大更改,可以认为测试套件也需要同样大的更改。 (但是,对我个人而言,“维护测试代码的工作量应该很小”的表述就足够了。)

结论:

对我来说,正如我将发现错误作为主要目标并将将测试套件的可维护性作为次要目标一样,这导致了以下后果:我接受必须同时测试实现细节以发现更多的错误。但是,尽管有这个事实,我仍然尽力降低维护工作量:我主要是通过应用以下机制来实现的,目的是在更改SUT的情况下简化测试套件的调整:

  • 首先,如果可以通过与实现无关的测试用例和与实现无关的测试用例来实现特定测试用例的目标,则更喜欢实现无关的测试用例。换句话说,不要使单个测试用例不必要地依赖于实现。
  • 第二,将实现细节隐藏在辅助函数的后面。可能有用于特定设置,拆卸,断言等的辅助功能。这是一种功能非常强大的机制,可以限制测试套件中实施细节的影响。