一直以来,我写Junit+Easymock测试都是这么来的:
public class SomeTest extends TestCase { private final IMocksControl control = EasyMock.createStrictControl(); private final Connection connection = control.createMock(Connection.class); @Override protected void tearDown() { control.verify(); // other cleanups. } public void test1() { expect(connection.createStatement()).andReturn(null); ... control.replay(); ... } }
不要细抠具体的mock用法,我这都是临时编的。但是这个测试类有两个肥肠严重的问题。说起来我一直都是这么做的,真是愧对付给我工资的那些老板们啊!
第一个问题:
JUnit里面不应该用field initializer的——或者说,不能用构造函数来初始化test fixture的。当调用new TestSuite(SomeTest.class)的时候,JUnit系统会对每一个testSomething()函数调用一次构造函数,生成一个独立的test instance. 这样做,保证了每个测试的独立性,最大程度地避免了某个test case影响其它test case的出现。
不过,“等等”,你说。这不是正好就对了么?每个test case都会调用一遍这些field initializer,没有任何问题了呀?
是呀,所以我才这么多年一直理直气壮,厚颜无耻地用这个idiom呀,用这个idiom。直到最近,同事中的java专家告诉我,这是不安全地,不道德地,是低级趣味地!
因为JUnit在没有执行test case,只是构造test suite的时候,就调用了这些构造函数,这就造成了几个额外问题:
1。这造成TestSuite的创建不够安全,有可能出现异常——我们希望异常只出现在test case运行的时候。并且TestSuite的创建速度也有可能受到影响。
2。我们无法保证一个TestCase只被运行一次。比如说RepeatedTest就会多次运行一个TestCase。所以保证TestCase至少看上去是immutable的就比较重要了。
第二个问题:
不应该在tearDown()里面调用verify()。如果test case失败了,首先这个verify()就没有调用的必要了。不应该用tearDown()来强制调用它。其次,如果verify()也失败(相当有可能地),那么后一个exception会冲掉前面的那个(JUnit 4里面修好了这个bug),而前面那个exception才是你真正需要的呀。
而且,如果verify()失败了,那么后面的other cleanups也被跳过去了,而它们才是最最重要的呀。
你说可气不可气?我还以为JUnit这么简单的东西,肯定不会用错的呢。
但是,让我不许定义final field,让我把所有的初始化都放在setUp里面,让我在tearDown里面一个接一个地套try-finally,这,这不是代码难看的问题,这是太没人性了!
我想啊想,终于顿悟了。这不是人民内部矛盾,而是不可调和的阶级冲突啊。
JUnit不同于TestNG, 它极度强调测试的隔离性,为此不惜禁止我们在一个测试类中共享一些有用的信息(比如,一个parse好的xml数据之类),每个test method都将使用一个单独的instance。可是,如此代价换来的居然还是一个不干不嘎的局面:我们还是不能假设一个instance只被用一次。它肯定不会被两个不同的test method使用地,——但是它可以被一个test method重复使用啊。哈,哈,哈。没想到吧?
到了JUnit 4, tearDown的问题解决了。我可以在每个@After函数中释放各自的资源,框架会保证它们都被执行。不过,我们还是不能在@After的函数中调用verify(), 还是因为对于verify()我们并不希望在测试本身失败的时候还调用它。
至于构造函数问题,没有任何改善,没有。同样,JUnit 4也没有正视广大人民群众对共享数据的呼声。不管TestSetup还是@BeforeClass都是要求你用evil static field。
忍无可忍之下,我又怒了。于是自己写了一个AjooTestSuite,偷偷从TestNG搞来了几个我一直非常眼馋的annotation: @BeforeTest, @AfterTest, @BeforeSuite, @AfterSuite, @ExceptionExpected。又自己填了两个@Verify和@Shared。前者用来在测试没出问题的情况下做一个公用的verify,比如verify mock object;后者用来在test case之间共享数据。一个使用AjooTestSuite的测试类可以这么写:
public class SomeTest extends TestCase { public static Test suite() { return new AjooTestSuite(SomeTest.class); } // 哈哈。终于可以用final和initializer了! private final IMocksControl control = EasyMock.createStrictControl(); private final Connection conn = control.createMock(Connection.class); @Verify public void verifyMocks() { control.verify(); } @Shared public static XmlObject provideXml() { // ... read xml file and parse. return xmlObj; } private final XmlObj; // the shared xmlObj will be injected for each test case. public SomeTest(String name, XmlObject obj) { super(name); this.xmlOj = obj; } @BeforeSuite public static void initialize() { // some suite level initialization. No more TestSetup! } @AfterSuite public static void deinitialize() { // suite level deinitialization. } @BeforeTest public void setupMocks() { expect(conn.createStatement()).andReturn(null); } @ExceptionExpected(NullPointerException.class) public void test1() { throw new NullPointerException(); } }
写完之后,我这个得意呀。终于不用忍受JUnit屎一样的限制了。构造函数只有在测试运行的时候才调用,换句话说,这回test case是绝对immutable的了,绝对线程安全。耶!
除此之外,还加上了一些流行的annotation。@BeforeTest, @AfterTest, @BeforeSuite, @AfterSuite,@ExceptionExpected不用说,都跟TestNG一样的语义。@Verify用来搞类似verify mock之类的事情;@Shared标注的静态函数在每次test suite执行的时候会被调用一次,返回的数据会被保存并被注射给每一个test case instance。
完美呀,完美。这样一来,也不用转移到TestNG了,也不用忍受一些工具集成的问题升级到JUnit4了。就是一个新的TestSuite而已,100%向后兼容。
可是,事实证明,我说的太早了。我的java专家同事又给我泼了一瓢凉水:
现在,你在Eclipse/Intellij里面点运行,没问题,它会知道去寻找suite()函数,然后用你的自定义test suite。可是,如果你然后点某一个单独的test case, 比如test1, 然后说"run"。会发生什么哩?嘿嘿,它不再去找你的suite()函数啦,啦,啦,啦,啦(回声逐渐消失)。它直接跑到你的类里面去找这个函数来调用了。Surprise!
天啊。该死的Eclipse, 它难道不会调用suite(), 然后在suite()里面找么?
可仔细想想,又不是IDE的错。即使它调用suite()又如何?没有一个TestSuite.getTest(String name)的API供它调用啊。实际上,JUnit的TestCase也并不强制保证每个Test都有一个id的。
于是,我三天的工作白费了。呜呜呜!
总而言之,言而总之,千言万语,咬牙切齿汇成一句话:JUnit Sucks!