如何在模拟和单元测试中需要时抛出SqlException?

111

我正在尝试测试项目中的一些异常,其中捕获的异常之一是 SQlException

似乎你不能使用 new SqlException(),所以我不确定如何抛出异常,特别是不会通过某种方式调用数据库(由于这些是单元测试,通常建议不要调用数据库因为它很慢)。

我正在使用 NUnit 和 Moq,但我不知道如何模拟它。

回应一些基于 ADO.NET 的答案,注意我正在使用 Linq to Sql。所以那些东西在幕后进行。

根据 @MattHamilton 的要求提供更多信息:

System.ArgumentException : Type to mock must be an interface or an abstract or non-sealed class.       
  at Moq.Mock`1.CheckParameters()
  at Moq.Mock`1..ctor(MockBehavior behavior, Object[] args)
  at Moq.Mock`1..ctor(MockBehavior behavior)
  at Moq.Mock`1..ctor()

尝试模拟时,将帖子发布到第一行

 var ex = new Mock<System.Data.SqlClient.SqlException>();
 ex.SetupGet(e => e.Message).Returns("Exception message");

1
你说得对。我已经更新了我的回复,但现在可能没有什么帮助了。不过,DbException 可能是更好的异常类型可以捕获,所以请考虑使用它。 - Matt Hamilton
实际有效的答案会产生各种不同的异常消息。明确需要哪种类型可能会有所帮助。例如,“我需要一个包含异常号码18487的SqlException,表示指定的密码已过期。” 看起来这样的解决方案更适合单元测试。 - Mike Christian
16个回答

112

我有一个解决方案。不确定它是天才还是疯狂。

以下代码将创建一个新的SqlException:

public SqlException MakeSqlException() {
    SqlException exception = null;
    try {
        SqlConnection conn = new SqlConnection(@"Data Source=.;Database=GUARANTEED_TO_FAIL;Connection Timeout=1");
        conn.Open();
    } catch(SqlException ex) {
        exception = ex;
    }
    return(exception);
}

然后您可以像这样使用它(此示例使用Moq)

mockSqlDataStore
    .Setup(x => x.ChangePassword(userId, It.IsAny<string>()))
    .Throws(MakeSqlException());

这样你就可以在你的repositories、handlers和controllers中测试SqlException错误处理。

现在我需要去躺下了。


15
太棒了!我对这个解决方案做了一个修改,以节省等待连接的时间:new SqlConnection(@"Data Source=.;Database=GUARANTEED_TO_FAIL;Connection Timeout=1") - Joanna Derks
3
我喜欢你在回答中加入的情感。哈哈,谢谢你提供这个解决方案。这太简单了,我不知道为什么一开始没有想到。再次感谢。 - pqsk
4
好的解决方案,只要确保你的本地计算机上没有名为“GUARANTEED_TO_FAIL”的数据库即可 ;) - Amit G
2
K.I.S.S. 的一个很好的例子。 - Lup
1
我知道这个问题非常古老,但我最近遇到了这个问题。这绝对是这里最好的答案。如果你不得不使用反射,那么最好有一个很好的理由。让框架来完成它的任务吧! - rdelgado-incinc
显示剩余2条评论

100
你可以使用反射来实现这个,当微软进行更改时你需要维护它,但我刚刚测试过它确实可以工作:
public class SqlExceptionCreator
{
    private static T Construct<T>(params object[] p)
    {
        var ctors = typeof(T).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance);
        return (T)ctors.First(ctor => ctor.GetParameters().Length == p.Length).Invoke(p);
    }

    internal static SqlException NewSqlException(int number = 1)
    {
        SqlErrorCollection collection = Construct<SqlErrorCollection>();
        SqlError error = Construct<SqlError>(number, (byte)2, (byte)3, "server name", "error message", "proc", 100);

        typeof(SqlErrorCollection)
            .GetMethod("Add", BindingFlags.NonPublic | BindingFlags.Instance)
            .Invoke(collection, new object[] { error });


        return typeof(SqlException)
            .GetMethod("CreateException", BindingFlags.NonPublic | BindingFlags.Static,
                null,
                CallingConventions.ExplicitThis,
                new[] { typeof(SqlErrorCollection), typeof(string) },
                new ParameterModifier[] { })
            .Invoke(null, new object[] { collection, "7.0.0" }) as SqlException;
    }
}      

这还允许您控制SqlException的编号,这可能很重要。


2
这种方法是可行的,你只需要更具体地指定你想要的CreateException方法,因为有两个重载。将GetMethod调用更改为: .GetMethod("CreateException", BindingFlags.NonPublic | BindingFlags.Static, null, CallingConventions.ExplicitThis, new[] {typeof (SqlErrorCollection), typeof (string)}, new ParameterModifier[] {})然后它就可以工作了。 - Erik Nordenhök
可以运行。太棒了。 - Nick Patsaris
5
感谢所有人在评论中提供的更正意见,此内容已经被转化为要点。https://gist.github.com/timabell/672719c63364c497377f - 非常感谢让我走出这个黑暗的地方。 - Tim Abell
2
Ben J Anderson的版本允许您除了错误代码之外还指定消息。https://gist.github.com/benjanderson/07e13d9a2068b32c2911 - Tony
17
为了让这个方法在.NET Core 2.0中起作用,需要将NewSqlException方法中的第二行改为以下内容: SqlError error = Construct<SqlError>(number, (byte)2, (byte)3, "服务器名称", "错误消息", "存储过程", 100, null); - Chuck Spencer
显示剩余3条评论

35

根据情况,我通常更喜欢使用GetUninitializedObject而不是调用ConstructorInfo。您只需要注意它不会调用构造函数 - 根据MSDN的说明:“因为对象的新实例被初始化为零并且没有运行任何构造函数,所以该对象可能不代表该对象视为有效状态。”但我认为这比依赖于某个特定构造函数更加可靠。

[TestMethod]
[ExpectedException(typeof(System.Data.SqlClient.SqlException))]
public void MyTestMethod()
{
    throw Instantiate<System.Data.SqlClient.SqlException>();
}

public static T Instantiate<T>() where T : class
{
    return System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(T)) as T;
}

7
这对我很有用。当您拥有对象后,要设置异常消息,请执行以下操作:typeof(SqlException).GetField("_message", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(exception, "我的自定义SQL消息"); - Phil Cooper
10
我扩展了这个功能以反映出错误消息和错误代码。https://gist.github.com/benjanderson/07e13d9a2068b32c2911 - Ben Anderson

15

Microsoft.Data.SqlClient

如果您正在使用新的 Microsoft.Data.SqlClient Nuget 包,则可以使用此帮助程序方法:

public static class SqlExceptionCreator
{
    public static SqlException Create(int number)
    {
        Exception? innerEx = null;
        var c = typeof(SqlErrorCollection).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance);
        SqlErrorCollection errors = (c[0].Invoke(null) as SqlErrorCollection)!;
        var errorList = (errors.GetType().GetField("_errors", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(errors) as List<object>)!;
        c = typeof(SqlError).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance);
        var nineC = c.FirstOrDefault(f => f.GetParameters().Length == 9)!;
        SqlError sqlError = (nineC.Invoke(new object?[] { number, (byte)0, (byte)0, "", "", "", (int)0, (uint)0, innerEx}) as SqlError)!;
        errorList.Add(sqlError);
        SqlException ex = (Activator.CreateInstance(typeof(SqlException), BindingFlags.NonPublic | BindingFlags.Instance, null, new object?[] { "test", errors,
            innerEx, Guid.NewGuid() }, null) as SqlException)!;
        return ex;
    }
}

如果您正在使用System.Data.SqlClient,请查看我对此代码的修改。谢谢,jjxtra! - DSoa
3
这个可以在.NET 6和microsoft.data.sqlclient 2.1.4上运行。 - Julian
仍然在抱怨这个该死的异常没有一种不使用反射就能做到这一点的方法。 - jjxtra

13

编辑 糟糕:我没有意识到SqlException是密封的。我一直在模拟抽象类DbException。

你无法创建新的SqlException,但是你可以模拟一个DbException,因为SqlException继承自它。试试这个:

var ex = new Mock<DbException>();
ex.ExpectGet(e => e.Message, "Exception message");

var conn = new Mock<SqlConnection>();
conn.Expect(c => c.Open()).Throws(ex.Object);

当方法尝试打开连接时,您的异常被抛出。

如果您希望读取除模拟异常上的“Message”属性之外的任何内容,则不要忘记期望(或设置,具体取决于您使用的Moq版本)这些属性的“get”。


你应该为“Number”添加期望值,以便确定它是哪种类型的异常(死锁、超时等)。 - Sam Saffron
当你使用linq to sql时怎么办?我实际上不需要打开它(这是由程序自动完成的)。 - chobo2
如果您正在使用Moq,那么可能是在模拟某种数据库操作。设置它在发生这种情况时被抛出。 - Matt Hamilton
哦,我试了一下,它甚至让我都无法模拟。我得到一个错误提示,说它无法进行模拟。请参见原始帖子。 - chobo2
我在 @chobo2 发布了我的 moq 答案。没有反映,但是你大概在过去的6年中已经弄清楚了。(: - Rob
显示剩余2条评论

9
由于您正在使用Linq to Sql,这里提供了一个使用NUnit和Moq测试您提到的场景的示例。我不知道您的DataContext的确切细节以及可用内容。请根据您的需求进行编辑。
您需要使用自定义类包装DataContext,无法使用Moq模拟DataContext。由于SqlException是密封的,因此也无法模拟它。您需要使用自己的异常类来包装它。完成这两件事情并不困难。
让我们从创建测试开始:
[Test]
public void FindBy_When_something_goes_wrong_Should_handle_the_CustomSqlException()
{
    var mockDataContextWrapper = new Mock<IDataContextWrapper>();
    mockDataContextWrapper.Setup(x => x.Table<User>()).Throws<CustomSqlException>();

    IUserResository userRespoistory = new UserRepository(mockDataContextWrapper.Object);
    // Now, because we have mocked everything and we are using dependency injection.
    // When FindBy is called, instead of getting a user, we will get a CustomSqlException
    // Now, inside of FindBy, wrap the call to the DataContextWrapper inside a try catch
    // and handle the exception, then test that you handled it, like mocking a logger, then passing it into the repository and verifying that logMessage was called
    User user = userRepository.FindBy(1);
}

让我们来实现测试,首先使用仓储模式封装Linq to Sql调用:

public interface IUserRepository
{
    User FindBy(int id);
}

public class UserRepository : IUserRepository
{
    public IDataContextWrapper DataContextWrapper { get; protected set; }

    public UserRepository(IDataContextWrapper dataContextWrapper)
    {
        DataContextWrapper = dataContextWrapper;
    }

    public User FindBy(int id)
    {
        return DataContextWrapper.Table<User>().SingleOrDefault(u => u.UserID == id);
    }
}

接下来,创建IDataContextWrapper,如下所示。您可以在这个博客文章中查看有关此主题的信息。我的略有不同:

public interface IDataContextWrapper : IDisposable
{
    Table<T> Table<T>() where T : class;
}

接下来创建CustomSqlException类:

public class CustomSqlException : Exception
{
 public CustomSqlException()
 {
 }

 public CustomSqlException(string message, SqlException innerException) : base(message, innerException)
 {
 }
}

这是一个IDataContextWrapper的样例实现:
public class DataContextWrapper<T> : IDataContextWrapper where T : DataContext, new()
{
 private readonly T _db;

 public DataContextWrapper()
 {
        var t = typeof(T);
     _db = (T)Activator.CreateInstance(t);
 }

 public DataContextWrapper(string connectionString)
 {
     var t = typeof(T);
     _db = (T)Activator.CreateInstance(t, connectionString);
 }

 public Table<TableName> Table<TableName>() where TableName : class
 {
        try
        {
            return (Table<TableName>) _db.GetTable(typeof (TableName));
        }
        catch (SqlException exception)
        {
            // Wrap the SqlException with our custom one
            throw new CustomSqlException("Ooops...", exception);
        }
 }

 // IDispoable Members
}

5

不确定这是否有帮助,但对于这个人似乎起作用了(相当聪明)。

try
{
    SqlCommand cmd =
        new SqlCommand("raiserror('Manual SQL exception', 16, 1)",DBConn);
    cmd.ExecuteNonQuery();
}
catch (SqlException ex)
{
    string msg = ex.Message; // msg = "Manual SQL exception"
}

来源: http://smartypeeps.blogspot.com/2006/06/how-to-throw-sqlexception-in-c.html

该文章介绍了如何在C#中抛出SqlException异常。

我尝试过这个,但是你仍然需要一个打开的 SqlConnection 对象才能抛出 SqlException。 - MusiGenesis
我使用linq to sql,所以我不会做这个ado.net的东西。这一切都在幕后进行。 - chobo2

4

这些解决方案感觉过于臃肿。

构造函数是internal的,是的。

(不使用反射的情况下,最简单的方法只是真正地创建此异常....

   instance.Setup(x => x.MyMethod())
            .Callback(() => new SqlConnection("Server=pleasethrow;Database=anexception;Connection Timeout=1").Open());

也许还有另一种方法,不需要等待1秒钟就能抛出异常。


哈哈...如此简单,我不知道为什么没想到这个...完美无压力,我可以在任何地方做到这一点。 - hal9000
2
设置消息和错误代码怎么样?看起来你的解决方案不允许这样做。 - Sasuke Uchiha
@Sasuke Uchiha 当然,它不行。其他解决方案可以。但是,如果你只需要抛出这种类型的异常,想避免反射并且不想写太多代码,那么你可以使用这个解决方案。 - Billy Jake O'Connor
最佳答案及精选答案... - Durgesh Pandey

3
我建议使用这种方法。
    /// <summary>
    /// Method to simulate a throw SqlException
    /// </summary>
    /// <param name="number">Exception number</param>
    /// <param name="message">Exception message</param>
    /// <returns></returns>
    public static SqlException CreateSqlException(int number, string message)
    {
        var collectionConstructor = typeof(SqlErrorCollection)
            .GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, //visibility
                null, //binder
                new Type[0],
                null);
        var addMethod = typeof(SqlErrorCollection).GetMethod("Add", BindingFlags.NonPublic | BindingFlags.Instance);
        var errorCollection = (SqlErrorCollection)collectionConstructor.Invoke(null);
        var errorConstructor = typeof(SqlError).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null,
            new[]
            {
                typeof (int), typeof (byte), typeof (byte), typeof (string), typeof(string), typeof (string),
                typeof (int), typeof (uint)
            }, null);
        var error =
            errorConstructor.Invoke(new object[] { number, (byte)0, (byte)0, "server", "errMsg", "proccedure", 100, (uint)0 });
        addMethod.Invoke(errorCollection, new[] { error });
        var constructor = typeof(SqlException)
            .GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, //visibility
                null, //binder
                new[] { typeof(string), typeof(SqlErrorCollection), typeof(Exception), typeof(Guid) },
                null); //param modifiers
        return (SqlException)constructor.Invoke(new object[] { message, errorCollection, new DataException(), Guid.NewGuid() });
    }

2
从审核队列中:我可以请求您在您的答案周围添加更多的上下文吗?仅有代码的答案很难理解。如果您能在帖子中添加更多信息,这将有助于提问者和未来的读者。 - RBT
1
您可能希望通过编辑帖子本身来添加此信息。与评论相比,帖子是更好的位置,以维护与答案相关的信息。 - RBT
这不再起作用,因为 SqlException 没有构造函数,而 errorConstructor 将为空。 - Emad
@Emad,你用了什么方法来解决这个问题? - Sasuke Uchiha

2
这应该可以正常工作:
SqlConnection bogusConn = 
    new SqlConnection("Data Source=myServerAddress;Initial
    Catalog=myDataBase;User Id=myUsername;Password=myPassword;");
bogusConn.Open();

这需要一点时间才会抛出异常,所以我认为这样做会更快:

SqlCommand bogusCommand = new SqlCommand();
bogusCommand.ExecuteScalar();

代码由Hacks-R-Us提供。

更新: 不,第二种方法会抛出ArgumentException而不是SqlException。

更新2: 这个方法运行速度更快(SqlException在不到一秒钟内被抛出):

SqlConnection bogusConn = new SqlConnection("Data Source=localhost;Initial
    Catalog=myDataBase;User Id=myUsername;Password=myPassword;Connection
    Timeout=1");
bogusConn.Open();

这是在我看到这个SU页面之前我自己的实现,因为超时不可接受而寻找另一种方式。您的 Update 2 很好,但仍然是一秒钟。对于单元测试集来说并不好,因为它无法扩展。 - Jon Davis

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接