37

我们发现我们为 C#/C++ 代码编写的单元测试确实得到了回报。但是我们在存储过程中仍然有数千行业务逻辑,只有在我们的产品向大量用户推出时才会真正得到愤怒的测试。

更糟糕的是,其中一些存储过程最终会变得很长,因为在 SP 之间传递临时表时会降低性能。这阻止了我们进行重构以使代码更简单。

我们已经多次尝试围绕我们的一些关键存储过程构建单元测试(主要是测试性能),但发现为这些测试设置测试数据真的很困难。例如,我们最终会复制测试数据库。除此之外,测试最终对更改非常敏感,甚至对存储过程的最小更改也是如此。or table 需要对测试进行大量更改。因此,在由于这些数据库测试间歇性失败而导致许多构建中断之后,我们不得不将它们从构建过程中拉出来。

所以,我的主要问题是:有没有人成功地为他们的存储过程编写过单元测试?

我的问题的第二部分是使用 linq 进行单元测试是否会/更容易?

我在想,不必设置测试数据表,您可以简单地创建一个测试对象集合,并在“linq to objects”的情况下测试您的 linq 代码?(我对 linq 完全陌生,所以不知道这是否可行)

4

16 回答 16

12

不久前我遇到了同样的问题,发现如果我为数据访问创建了一个简单的抽象基类,允许我注入连接和事务,我可以对我的存储过程进行单元测试,看看它们是否在 SQL 中完成了我的工作要求他们做然后回滚,所以没有任何测试数据留在数据库中。

这比通常的“运行脚本来设置我的测试数据库,然后在测试运行后清理垃圾/测试数据”感觉更好。这也感觉更接近于单元测试,因为这些测试可以单独运行而没有大量的“在我运行这些测试之前,数据库中的所有内容都需要‘正常’”。

这是用于数据访问的抽象基类的片段

Public MustInherit Class Repository(Of T As Class)
    Implements IRepository(Of T)

    Private mConnectionString As String = ConfigurationManager.ConnectionStrings("Northwind.ConnectionString").ConnectionString
    Private mConnection As IDbConnection
    Private mTransaction As IDbTransaction

    Public Sub New()
        mConnection = Nothing
        mTransaction = Nothing
    End Sub

    Public Sub New(ByVal connection As IDbConnection, ByVal transaction As IDbTransaction)
        mConnection = connection
        mTransaction = transaction
    End Sub

    Public MustOverride Function BuildEntity(ByVal cmd As SqlCommand) As List(Of T)

    Public Function ExecuteReader(ByVal Parameter As Parameter) As List(Of T) Implements IRepository(Of T).ExecuteReader
        Dim entityList As List(Of T)
        If Not mConnection Is Nothing Then
            Using cmd As SqlCommand = mConnection.CreateCommand()
                cmd.Transaction = mTransaction
                cmd.CommandType = Parameter.Type
                cmd.CommandText = Parameter.Text
                If Not Parameter.Items Is Nothing Then
                    For Each param As SqlParameter In Parameter.Items
                        cmd.Parameters.Add(param)
                    Next
                End If
                entityList = BuildEntity(cmd)
                If Not entityList Is Nothing Then
                    Return entityList
                End If
            End Using
        Else
            Using conn As SqlConnection = New SqlConnection(mConnectionString)
                Using cmd As SqlCommand = conn.CreateCommand()
                    cmd.CommandType = Parameter.Type
                    cmd.CommandText = Parameter.Text
                    If Not Parameter.Items Is Nothing Then
                        For Each param As SqlParameter In Parameter.Items
                            cmd.Parameters.Add(param)
                        Next
                    End If
                    conn.Open()
                    entityList = BuildEntity(cmd)
                    If Not entityList Is Nothing Then
                        Return entityList
                    End If
                End Using
            End Using
        End If

        Return Nothing
    End Function
End Class

接下来,您将看到使用上述基础获取产品列表的示例数据访问类

Public Class ProductRepository
    Inherits Repository(Of Product)
    Implements IProductRepository

    Private mCache As IHttpCache

    'This const is what you will use in your app
    Public Sub New(ByVal cache As IHttpCache)
        MyBase.New()
        mCache = cache
    End Sub

    'This const is only used for testing so we can inject a connectin/transaction and have them roll'd back after the test
    Public Sub New(ByVal cache As IHttpCache, ByVal connection As IDbConnection, ByVal transaction As IDbTransaction)
        MyBase.New(connection, transaction)
        mCache = cache
    End Sub

    Public Function GetProducts() As System.Collections.Generic.List(Of Product) Implements IProductRepository.GetProducts
        Dim Parameter As New Parameter()
        Parameter.Type = CommandType.StoredProcedure
        Parameter.Text = "spGetProducts"
        Dim productList As List(Of Product)
        productList = MyBase.ExecuteReader(Parameter)
        Return productList
    End Function

    'This function is used in each class that inherits from the base data access class so we can keep all the boring left-right mapping code in 1 place per object
    Public Overrides Function BuildEntity(ByVal cmd As System.Data.SqlClient.SqlCommand) As System.Collections.Generic.List(Of Product)
        Dim productList As New List(Of Product)
        Using reader As SqlDataReader = cmd.ExecuteReader()
            Dim product As Product
            While reader.Read()
                product = New Product()
                product.ID = reader("ProductID")
                product.SupplierID = reader("SupplierID")
                product.CategoryID = reader("CategoryID")
                product.ProductName = reader("ProductName")
                product.QuantityPerUnit = reader("QuantityPerUnit")
                product.UnitPrice = reader("UnitPrice")
                product.UnitsInStock = reader("UnitsInStock")
                product.UnitsOnOrder = reader("UnitsOnOrder")
                product.ReorderLevel = reader("ReorderLevel")
                productList.Add(product)
            End While
            If productList.Count > 0 Then
                Return productList
            End If
        End Using
        Return Nothing
    End Function
End Class

现在在您的单元测试中,您还可以从一个非常简单的基类继承您的设置/回滚工作 - 或者在每个单元测试的基础上保持它

下面是我使用的简单测试基类

Imports System.Configuration
Imports System.Data
Imports System.Data.SqlClient
Imports Microsoft.VisualStudio.TestTools.UnitTesting

Public MustInherit Class TransactionFixture
    Protected mConnection As IDbConnection
    Protected mTransaction As IDbTransaction
    Private mConnectionString As String = ConfigurationManager.ConnectionStrings("Northwind.ConnectionString").ConnectionString

    <TestInitialize()> _
    Public Sub CreateConnectionAndBeginTran()
        mConnection = New SqlConnection(mConnectionString)
        mConnection.Open()
        mTransaction = mConnection.BeginTransaction()
    End Sub

    <TestCleanup()> _
    Public Sub RollbackTranAndCloseConnection()
        mTransaction.Rollback()
        mTransaction.Dispose()
        mConnection.Close()
        mConnection.Dispose()
    End Sub
End Class

最后 - 下面是一个使用该测试基类的简单测试,它显示了如何测试整个 CRUD 循环以确保所有 sproc 都完成它们的工作并且您的 ado.net 代码正确地进行左右映射

我知道这不会测试上述数据访问示例中使用的“spGetProducts”存储过程,但是您应该看到这种单元测试存储过程方法背后的力量

Imports SampleApplication.Library
Imports System.Collections.Generic
Imports Microsoft.VisualStudio.TestTools.UnitTesting

<TestClass()> _
Public Class ProductRepositoryUnitTest
    Inherits TransactionFixture

    Private mRepository As ProductRepository

    <TestMethod()> _
    Public Sub Should-Insert-Update-And-Delete-Product()
        mRepository = New ProductRepository(New HttpCache(), mConnection, mTransaction)
        '** Create a test product to manipulate throughout **'
        Dim Product As New Product()
        Product.ProductName = "TestProduct"
        Product.SupplierID = 1
        Product.CategoryID = 2
        Product.QuantityPerUnit = "10 boxes of stuff"
        Product.UnitPrice = 14.95
        Product.UnitsInStock = 22
        Product.UnitsOnOrder = 19
        Product.ReorderLevel = 12
        '** Insert the new product object into SQL using your insert sproc **'
        mRepository.InsertProduct(Product)
        '** Select the product object that was just inserted and verify it does exist **'
        '** Using your GetProductById sproc **'
        Dim Product2 As Product = mRepository.GetProduct(Product.ID)
        Assert.AreEqual("TestProduct", Product2.ProductName)
        Assert.AreEqual(1, Product2.SupplierID)
        Assert.AreEqual(2, Product2.CategoryID)
        Assert.AreEqual("10 boxes of stuff", Product2.QuantityPerUnit)
        Assert.AreEqual(14.95, Product2.UnitPrice)
        Assert.AreEqual(22, Product2.UnitsInStock)
        Assert.AreEqual(19, Product2.UnitsOnOrder)
        Assert.AreEqual(12, Product2.ReorderLevel)
        '** Update the product object **'
        Product2.ProductName = "UpdatedTestProduct"
        Product2.SupplierID = 2
        Product2.CategoryID = 1
        Product2.QuantityPerUnit = "a box of stuff"
        Product2.UnitPrice = 16.95
        Product2.UnitsInStock = 10
        Product2.UnitsOnOrder = 20
        Product2.ReorderLevel = 8
        mRepository.UpdateProduct(Product2) '**using your update sproc
        '** Select the product object that was just updated to verify it completed **'
        Dim Product3 As Product = mRepository.GetProduct(Product2.ID)
        Assert.AreEqual("UpdatedTestProduct", Product2.ProductName)
        Assert.AreEqual(2, Product2.SupplierID)
        Assert.AreEqual(1, Product2.CategoryID)
        Assert.AreEqual("a box of stuff", Product2.QuantityPerUnit)
        Assert.AreEqual(16.95, Product2.UnitPrice)
        Assert.AreEqual(10, Product2.UnitsInStock)
        Assert.AreEqual(20, Product2.UnitsOnOrder)
        Assert.AreEqual(8, Product2.ReorderLevel)
        '** Delete the product and verify it does not exist **'
        mRepository.DeleteProduct(Product3.ID)
        '** The above will use your delete product by id sproc **'
        Dim Product4 As Product = mRepository.GetProduct(Product3.ID)
        Assert.AreEqual(Nothing, Product4)
    End Sub

End Class

我知道这是一个很长的例子,但它有助于为数据访问工作提供一个可重用的类,并为我的测试提供另一个可重用的类,因此我不必一遍又一遍地进行设置/拆卸工作;)

于 2008-08-24T17:20:14.487 回答
10

你试过DBUnit吗?它旨在对您的数据库进行单元测试,并且只是对您的数据库进行单元测试,而无需通过您的 C# 代码。

于 2008-08-15T15:35:02.907 回答
6

如果您考虑单元测试倾向于促进的那种代码:小型高内聚和低耦合例程,那么您应该几乎能够看到至少部分问题可能出在哪里。

在我的愤世嫉俗的世界中,存储过程是 RDBMS 世界长期以来试图说服您将业务处理转移到数据库中的一部分,当您考虑到服务器许可成本往往与处理器数量等因素相关时,这是有道理的。您在数据库中运行的东西越多,它们从您那里获得的东西就越多。

但我的印象是你实际上更关心性能,这根本不是单元测试的保留。单元测试应该是相当原子的,旨在检查行为而不是性能。在这种情况下,您几乎可以肯定需要生产级负载来检查查询计划。

我认为您需要不同类别的测试环境。假设安全不是问题,我建议将生产副本作为最简单的方法。然后对于每个候选版本,您从以前的版本开始,使用您的发布过程进行迁移(这将为那些提供良好的测试作为副作用)并运行您的计时。

类似的东西。

于 2008-08-15T15:51:15.433 回答
6

测试存储过程的关键是编写一个脚本,该脚本使用预先计划好的数据填充空白数据库,以便在调用存储过程时产生一致的行为。

我必须投票赞成大力支持存储过程,并将您的业务逻辑放在我(和大多数 DBA)认为属于数据库的位置。

我知道,作为软件工程师,我们希望用我们最喜欢的语言编写精美的重构代码,以包含我们所有的重要逻辑,但是大容量系统中的性能现实以及数据完整性的关键性质,要求我们做出一些妥协. Sql 代码可能丑陋、重复且难以测试,但我无法想象在不完全控制查询设计的情况下调整数据库的难度。

我经常被迫完全重新设计查询,包括对数据模型的更改,以使事情在可接受的时间内运行。使用存储过程,我可以确保更改对调用者是透明的,因为存储过程提供了如此出色的封装。

于 2008-08-15T17:27:51.963 回答
4

我假设您希望在 MSSQL 中进行单元测试。查看 DBUnit,它对 MSSQL 的支持存在一些限制。例如,它不支持 NVarChar。 下面是一些真实的用户以及他们在使用 DBUnit 时遇到的问题。

于 2008-08-15T15:40:50.320 回答
4

好问题。

我也有类似的问题,而且我走的是阻力最小的道路(无论如何对我来说)。

还有很多其他解决方案,其他人已经提到过。他们中的许多人更好/更纯粹/更适合其他人。

我已经在使用 Testdriven.NET/MbUnit 来测试我的 C#,所以我只是在每个项目中添加了测试来调用该应用程序使用的存储过程。

我知道我知道。这听起来很糟糕,但我需要的是通过一些测试开始工作,然后从那里开始。这种方法意味着尽管我的覆盖率很低,但我在测试将调用它们的代码的同时测试了一些存储的过程。这有一些逻辑。

于 2008-10-02T11:40:52.273 回答
3

我的情况与原始海报完全相同。它归结为性能与可测试性。我偏向于可测试性(使其工作,使其正确,使其快速),这表明将业务逻辑排除在数据库之外。数据库不仅缺乏在 Java 等语言中发现的测试框架、代码分解结构以及代码分析和导航工具,而且高度分解的数据库代码也很慢(而高度分解的 Java 代码则不然)。

但是,我确实认识到数据库集处理的强大功能。如果使用得当,SQL 可以用很少的代码完成一些非常强大的事情。所以,我可以接受一些基于集合的逻辑存在于数据库中,即使我仍然会尽我所能对它进行单元测试。

在相关的说明中,似乎非常长的程序数据库代码通常是其他东西的症状,我认为这样的代码可以转换为可测试的代码而不会导致性能下降。理论上,这样的代码通常代表周期性地处理大量数据的批处理。如果要将这些批处理转换为更小的实时业务逻辑块,在输入数据更改时运行,则该逻辑可以在中间层(可以对其进行测试)上运行而不会影响性能(因为这项工作是实时分小块完成的)。作为副作用,这也消除了批处理错误处理的长反馈循环。当然,这种方法并不适用于所有情况,但它可能适用于某些情况。还,如果您的系统中有大量此类无法测试的批处理数据库代码,那么拯救之路可能漫长而艰巨。YMMV。

于 2008-10-02T06:08:33.443 回答
2

但我的印象是你实际上更关心性能,这根本不是单元测试的保留。单元测试应该是相当原子的,旨在检查行为而不是性能。在这种情况下,您几乎可以肯定需要生产级负载来检查查询计划。

我认为这里有两个截然不同的测试领域:性能和存储过程的实际逻辑。

我给出了过去测试 db 性能的示例,幸运的是,我们已经达到了性能足够好的地步。

我完全同意数据库中所有业务逻辑的情况很糟糕,但这是我们在大多数开发人员加入公司之前继承的东西。

但是,我们现在正在为我们的新功能采用 Web 服务模型,并且我们一直在尝试尽可能地避免存储过程,将逻辑保留在 C# 代码中并在数据库中触发 SQLCommands(尽管 linq 现在将是首选方法)。现有的 SP 仍有一些用途,这就是我考虑对它们进行回顾性单元测试的原因。

于 2008-08-15T16:53:22.110 回答
2

您还可以尝试Visual Studio for Database Professionals。它主要是关于变更管理,但也有生成测试数据和单元测试的工具。

它相当昂贵。

于 2008-08-18T09:41:19.873 回答
1

我们使用DataFresh来回滚每个测试之间的更改,然后测试 sprocs 相对容易。

仍然缺乏的是代码覆盖工具。

于 2008-08-15T16:41:34.100 回答
1

我做穷人的单元测试。如果我很懒惰,那么测试只是几个具有潜在问题参数值的有效调用。

/*

--setup
Declare @foo int Set @foo = (Select top 1 foo from mytable)

--test
execute wish_I_had_more_Tests @foo

--look at rowcounts/look for errors
If @@rowcount=1 Print 'Ok!' Else Print 'Nokay!'

--Teardown
Delete from mytable where foo = @foo
*/
create procedure wish_I_had_more_Tests
as
select....
于 2009-04-29T03:32:32.710 回答
0

仅当您从存储过程中删除逻辑并将其重新实现为 linq 查询时,LINQ 才会简化这一点。肯定会更健壮,更容易测试。但是,听起来您的要求会排除这种情况。

TL;DR:你的设计有问题。

于 2008-08-15T15:44:00.127 回答
0

我们对调用 SP 的 C# 代码进行单元测试。
我们有构建脚本,创建干净的测试数据库。
我们在测试夹具期间连接和拆卸更大的夹具。
这些测试可能需要几个小时,但我认为这是值得的。

于 2008-08-15T15:51:21.313 回答
0

重构代码的一种选择(我承认这是一个丑陋的黑客)是通过 CPP(C 预处理器)M4(从未尝试过)等生成它。我有一个项目正在这样做,它实际上大部分是可行的。

我认为可能有效的唯一情况是 1) 作为 KLOC+ 存储过程的替代方案和 2) 这是我的情况,当项目的重点是看看你可以将技术推到多远(疯狂)时。

于 2008-08-15T17:09:23.060 回答
0

好家伙。sprocs 不适合(自动)单元测试。我通过在 t-sql 批处理文件中编写测试并手动检查打印语句的输出和结果来对复杂的存储过程进行排序“单元测试”。

于 2008-08-15T19:35:27.680 回答
0

对任何类型的数据相关编程进行单元测试的问题在于,您必须从一组可靠的测试数据开始。很大程度上还取决于存储过程的复杂性及其作用。对于修改许多表的非常复杂的过程,自动化单元测试将非常困难。

其他一些海报提到了一些自动化手动测试它们的简单方法,以及一些可以与 SQL Server 一起使用的工具。在 Oracle 方面,PL/SQL 大师 Steven Feuerstein 开发了一个免费的 PL/SQL 存储过程单元测试工具,称为 utPLSQL。

然而,他放弃了这项工作,然后将 Quest 的 Code Tester for PL/SQL 投入商业。Quest 提供可免费下载的试用版。我正处于尝试的边缘;我的理解是,它擅长处理建立测试框架的开销,这样你就可以只关注测试本身,它会保留测试,这样你就可以在回归测试中重用它们,这是最大的好处之一测试驱动开发。此外,它应该擅长的不仅仅是检查输出变量,并且确实提供了验证数据更改的功能,但我仍然需要自己仔细研究一下。我认为这些信息可能对 Oracle 用户有价值。

于 2008-08-18T00:07:13.277 回答