8

我正在尝试使一个非常大、非常遗留的项目可测试。

我们的大多数代码都使用了许多静态可用的服务。问题是这些很难模拟。他们曾经是单身人士。现在它们是伪单例——相同的静态接口,但函数委托给可以切换的实例对象。像这样:

class ServiceEveryoneNeeds
{
    public static IImplementation _implementation = new RealImplementation();

    public IEnumerable<FooBar> GetAllTheThings() { return _implementation.GetAllTheThings(); }
}

现在在我的单元测试中:

void MyTest()
{
    ServiceEveryoneNeeds._implementation = new MockImplementation();
}

到现在为止还挺好。在产品中,我们只需要一个实现。但是测试并行运行并且可能需要不同的模拟,所以我这样做了:

class Dependencies
{
     //set this in prod to the real impl
     public static IImplementation _realImplementation;

     //unit tests set these
     [ThreadStatic]
     public static IImplementation _mock;

     public static IImplementation TheImplementation
     { get {return _realImplementation ?? _mock; } }

     public static void Cleanup() { _mock = null; }
}

进而:

class ServiceEveryoneNeeds
{
     static IImplementation GetImpl() { return Dependencies.TheImplementation; }

     public static IEnumerable<FooBar> GetAllTheThings() {return GetImpl().GetAllTheThings(); }

}

//and
void MyTest()
{
    Dependencies._mock = new BestMockEver();
    //test
    Dependencies.Cleanup();
}

我们采用这条路线是因为构造函数将这些服务注入到每个需要它们的类中是一个庞大的项目。同时,这些是我们代码库中大多数功能所依赖的通用服务。

我知道这种模式是不好的,因为它隐藏了依赖关系,而不是使依赖关系显式的构造函数注入。

但是好处是:
- 我们可以立即开始单元测试,而不是进行 3 个月的重构然后进行单元测试。
- 我们仍然有全局变量,但这似乎比我们原来的要好。

虽然我们的依赖关系仍然是隐含的,但我认为这种方法比我们拥有的方法要好得多。除了隐藏的依赖关系之外,这是否比使用适当的 DI 容器更糟糕?我会遇到什么问题?

4

4 回答 4

4

它是一个糟糕的服务定位器。但你已经知道了。如果您的代码库如此庞大,为什么不开始部分迁移呢?向容器注册单例实例,并在您接触代码中的类时启动构造函数注入它们。然后,您可以将大部分零件留在(希望)工作状态,并在其他任何地方获得 DI 的好处。

理想情况下,没有 DI 的零件会随着时间的推移而收缩。您可以立即开始测试。

于 2012-05-24T18:48:27.790 回答
4

这称为环境上下文。如果正确使用和实施,使用环境上下文没有任何问题。可以使用环境上下文时有一些先决条件:

  1. 它必须是一个横切关注点,返回一些价值
  2. 您需要一个本地默认值
  3. 你必须确保null不能被分配。(改用Null 实现

对于不返回值的横切关注点,例如日志记录,您应该更喜欢拦截。对于其他不是横切关注点的依赖项,您应该进行构造函数注入。

但是,您的实现有几个问题(不会阻止分配 null、命名、无默认值)。以下是如何实现它:

public class SomeCrossCuttingConcern
{
     private static ISomeCrossCuttingConcern default = new DefaultSomeCrossCuttingConcern();

     [ThreadStatic]
     private static ISomeCrossCuttingConcern current;

     public static ISomeCrossCuttingConcern Default
     { 
         get { return default; }
         set 
         { 
             if (value == null) 
                 throw new ArgumentNullException(); 
             default = value; 
         } 
     }

     public static ISomeCrossCuttingConcern Current
     { 
         get 
         { 
             if (current == null)
                 current = default; 
             return current; 
         }

         set 
         { 
             if (value == null) 
                 throw new ArgumentNullException(); 
             current = value; 
         } 
     }

     public static void ResetToDefault() { current = null; }
}

环境上下文具有这样的优势,即您不会因横切关注点而污染您的 API。

但另一方面,关于测试,您的测试可能会变得依赖。例如,如果你忘记为一个测试设置你的模拟,如果模拟是由另一个测试设置的,它会正确运行。但是当它独立运行或以不同的顺序运行时,它将失败。它使测试更加困难。

于 2012-05-24T23:54:16.343 回答
1

依赖注入和使用 DI 容器确实是独立的任务,尽管一个自然会导致另一个。使用 DI 容器意味着代码具有一定的结构。这样的结构可能更容易阅读,并且在不深入了解隐藏依赖关系的情况下肯定更容易工作,因此更易于维护。

现在您不再依赖于结核,您已经实现了一种控制反转的形式。我认为这是一个更好的设计,并且代表了使代码更具可测试性的良好起点。听起来您从这一步中获得了一些立竿见影的价值。

显式依赖比隐式依赖更好吗(换句话说,DI 与环境上下文)?我倾向于说是的,但这实际上取决于成本与收益。好处取决于诸如引入错误的成本,您可能在代码中看到多少流失,调试的难度,谁将维护它,它的预期寿命等。

全局可变静态总是不好的。一些聪明的人可能会决定他们需要在调用时替换全局服务的实现,然后再替换它。如果他们事后不清理,这可能会出错。这可能是一个愚蠢的例子,但这种意想不到的副作用总是不好的,所以最好通过设计完全消除它们。您可以通过纪律和警惕来阻止它们,但这更难。

于 2012-05-24T18:45:25.190 回答
1

我觉得你做的还不错。您正在尝试使您的代码库可测试,而诀窍是通过小步骤来做到这一点。在阅读《有效地使用遗留代码》时,你会得到同样的建议。然而,你正在做的事情的缺点是,一旦你开始使用依赖注入,你将不得不再次重构你的代码库。但更重要的是,您将不得不更改大量测试代码。

我同意亚历克斯。更喜欢使用构造函数注入而不是使用环境上下文。您不必为此直接重构整个代码库,但是构造函数注入会使调用堆栈“冒泡”,并且您必须在某些地方进行“剪切”以防止它冒泡,因为这会迫使您做出整个代码库有很多变化。

我目前正在处理遗留代码库,不能使用 DI 容器(痛苦)。我仍然尽可能使用构造函数注入,这有时意味着我必须恢复对某些类型使用糟糕的依赖注入。这是我用来阻止“构造函数注入气泡”的技巧。不过,这比使用环境上下文要好得多。穷人的 DI 是次优的,但仍然允许您编写适当的单元测试,并且以后更容易打破默认构造函数。

于 2012-05-25T07:41:32.510 回答