數據庫是有問題的部分。 首先,您需要使用內存中的獨立數據庫(例如H2)將測試與外部數據庫分離。 Spring在很大程度上幫助了這一點,尤其是現在有了配置文件和嵌入式數據庫支持 。 第二個問題更加微妙。 雖然典型的Spring應用程序幾乎是完全無狀態的(無論好壞),但數據庫固有地是有狀態的。 這使集成測試變得復雜,因為編寫測試的第一個原則是它們應該彼此獨立且可重復。 如果一個測試將某些內容寫入數據庫,則另一個測試可能會失敗; 而且由于數據庫更改,相同的測試可能在后續調用中失敗。
顯然,Spring還可以通過一個非常巧妙的技巧來處理此問題 :在運行每個測試之前,Spring啟動一個新事務。 整個測試(包括其設置和拆除)在同一事務中運行,該事務在最后回滾。 這意味著測試期間所做的所有更改在數據庫中都是可見的,就像它們是持久的一樣。 但是,每次測試后的回滾將清除所有更改,并且下一個測試將在全新的數據庫上進行。 輝煌!
不幸的是,這不是關于Spring集成測試優勢的另一篇文章。 我想我已經編寫了成百上千個這樣的測試,而我真的很感謝Spring框架提供的透明支持。 但是我也遇到了這個舒適功能帶來的眾多怪異和不一致之處。 更糟的是,通常所謂的事務測試實際上隱藏了錯誤,使開發人員確信該軟件可以工作,而部署后卻會失敗! 這是一個不盡詳盡但令人大開眼界的問題集合:
@Test
public void shouldThrowLazyInitializationExceptionWhenFetchingLazyOneToManyRelationship() throws Exception {//givenfinal Book someBook = findAnyExistingBook();//whentry {someBook.reviews().size();fail();} catch(LazyInitializationException e) {//then}
}
這是Hibernate和spring集成測試中的一個已知問題。 Book是一個數據庫實體,與“評論”具有一對多的關系,默認情況下是惰性的。 findAnyExistingBook()只是從事務服務中讀取測試書。 現在有一點理論:只要將實體綁定到會話(如果使用JPA,則為EntityManager),它就可以延遲和透明地加載關系。 對我們而言,這意味著:只要它在交易范圍之內。 實體離開交易的那一刻,它就變得分離。 在此生命周期階段,實體不再連接到session / EntityManager(已經提交并關閉),并且任何獲取懶惰屬性的方法都將引發可怕的LazyInitializationException。 此行為實際上是在JPA中標準化的(異常類本身除外,后者是特定于供應商的)。
在本例中,我們正在調用.reviews()(Scala風格的“ getter”,我們也將盡快將測試用例轉換為ScalaTest),并期望看到Hibernate異常。 但是不會引發異常,并且應用程序繼續運行。 這是因為整個測試都在事務內運行,并且Book實體永遠不會超出事務范圍。 延遲加載在Spring集成測試中始終有效。
公平地說,我們在現實生活中永遠不會看到這樣的測試(除非您要進行測試以確保給定的集合是惰性的-不太可能)。 在現實生活中,我們正在測試僅在測試中起作用的業務邏輯。 但是,在部署之后,我們開始遇到LazyInitializationException。 但是我們測試了! Spring集成測試支持不僅隱藏了該問題 ,而且還鼓勵開發人員使用OpenSessionInViewFilter或OpenEntityManagerInViewFilter 。 換句話說:我們的測試不僅沒有發現代碼中的錯誤,而且還大大惡化了我們的整體體系結構和性能。 不是我所期望的。
目前,實現某些端到端功能時,我通常的工作流程是編寫后端測試,實現包括REST API的后端,并在一切運行順利時進行部署并繼續使用GUI。 后者是完全使用AJAX / JavaScript編寫的,因此我只需要部署一次并經常替換便宜的客戶端文件。 在此階段,我不想回到服務器來修復未發現的錯誤。
抑制LazyInitializationException是Spring集成測試中最著名的問題之一。 但這只是冰山一角。 這是一個更復雜的示例(它再次使用JPA,但是此問題在普通JDBC和任何其他持久性技術中也很明顯):
@Test
public void externalThreadShouldSeeChangesMadeInMainThread() throws Exception {//givenfinal Book someBook = findAnyExistingBook();someBook.setAuthor("Bruce Wayne");bookService.save(someBook);//whenfinal Future<Book> future = executorService.submit(new Callable<Book>() {@Overridepublic Book call() throws Exception {return bookService.findBy(someBook.id()).get();}});//thenassertThat(future.get().getAuthor()).isEqualTo("Bruce Wayne");
}
第一步,我們從數據庫中加載一些書籍并修改作者,然后保存一個實體。 然后,我們通過id在另一個線程中加載相同的實體。 該實體已經保存,因此可以保證該線程可以看到更改。 但是,情況并非如此,最后一步中的斷言證明了這一點。 發生了什么?
我們剛剛在ACID事務屬性中觀察到“ I”。 在提交事務之前,測試線程所做的更改對其他線程/連接不可見。 但是我們知道測試事務已提交! 這個小展示展示了在事務支持下編寫多線程集成測試有多么困難。 幾周前,當我想對啟用JDBCJobStore的 Quartz調度程序進行集成測試時,我學到了很難的方法。 無論我多么努力,這些工作從未被解雇。 事實證明,我正在Spring事務范圍內安排在Spring托管測試中的工作。 由于從未提交過事務,因此外部調度程序和工作線程無法在數據庫中看到新的作業記錄。 您花了幾個小時調試此類問題?
在談論調試時,對數據庫相關的測試失敗進行故障排除時會彈出相同的問題。 我可以將此簡單的H2 Web控制臺(瀏覽到localhost:8082)bean添加到我的測試配置中:
@Bean(initMethod = "start", destroyMethod = "stop")
def h2WebServer() = Server.createWebServer("-webDaemon", "-webAllowOthers")
但是在逐步進行測試時,我將永遠不會看到測試所做的更改。 我無法手動運行查詢以查明為什么返回錯誤結果。 另外,在進行故障排除時,我無法即時修改數據以更快地周轉。 我的數據庫位于另一個維度。
請仔細閱讀下一個測試,時間不長:
@Test
public void shouldNotSaveAndLoadChangesMadeToNotManagedEntity() throws Exception {//givenfinal Book unManagedBook = findAnyExistingBook();unManagedBook.setAuthor("Clark Kent");//whenfinal Book loadedBook = bookService.findBy(unManagedBook.id()).get();//thenassertThat(loadedBook.getAuthor()).isNotEqualTo("Clark Kent");
}
我們正在加載一本書并修改作者, 而沒有明確地堅持下去。 然后,我們再次從數據庫中加載它,并確保更改未保留。 猜猜是什么,我們已經以某種方式更新了對象!
如果您是經驗豐富的JPA / Hibernate用戶,那么您將確切地知道如何發生。 還記得我在上面描述附著/分離的實體時的情況嗎? 當實體仍附加到基礎EntityManager /會話時,它也具有其他權力。 JPA提供者有義務跟蹤對此類實體所做的更改,并在實體分離時將其自動傳播到數據庫(所謂的臟檢查)。
這意味著使用JPA實體修改的慣用方式是從數據庫中加載對象,使用setter執行必要的更改,僅此而已。 當實體分離時,JPA將發現它已被修改并為您發出UPDATE。 不需要merge()/ update(),可愛的對象抽象。 只要管理實體,此方法就起作用。 對分離的實體所做的更改將被靜默忽略,因為JPA提供程序對此類實體一無所知。 現在最好的部分–您幾乎不知道您的實體是否已附加,因為事務管理是透明的并且幾乎是不可見的。 這意味著只修改內存中的POJO實例,同時仍然認為更改是持久的,反之亦然,這太容易了!
我們可以測試嗎? 當然,我們只是做了–失敗了。 在我們的測試中,交易涉及整個測試方法,因此每個實體都受到管理。 同樣由于Hibernate L1緩存,即使尚未發布數據庫更新,我們也可以獲取完全相同的圖書實例。 這是事務測試隱藏問題而不是揭示問題的另一種情況(請參閱LazyInitializationException示例)。 更改將如測試中所預期的那樣傳播到數據庫,但是在部署后將被靜默忽略……
順便說一句,我是否提到過,一旦您放棄了對測試用例類的@Transactional注釋,到目前為止所有測試都通過了? 看看,來源永遠是可用的 。
這是令人興奮的。 我有一個事務性的deleteAndThrow(book)業務方法,該方法刪除給定的書并引發OppsException。 這是我通過的測試,證明代碼正確:
@Test
public void shouldDeleteEntityAndThrowAnException() throws Exception {//givenfinal Book someBook = findAnyExistingBook();try {//whenbookService.deleteAndThrow(someBook);fail();} catch (OppsException e) {//thenfinal Option<Book> deletedBook = bookService.findBy(someBook.id());assertThat(deletedBook.isEmpty()).isTrue();}}
返回了Scala的Option <Book> (您是否已經注意到Java代碼與用Scala編寫的服務和實體交互得很好嗎?),而不是null。 如果deleteBook.isEmpty()的結果為true,則表示未找到結果。 因此,似乎我們的代碼是正確的:實體已刪除,并且引發了異常。 是的,您是正確的,它在再次部署后會靜默失敗! 這次,Hibernate L1緩存知道該特定的book實例已刪除,因此即使在刷新更改到數據庫之前,它也返回null。 但是,從服務拋出的OppsException會回滾事務,并丟棄DELETE! 但是測試通過了,只是因為Spring管理著這個微小的額外事務,并且斷言發生在該事務內。 毫秒后,事務回滾,恢復已刪除的實體。
顯然,解決方案是為OppsException添加noRollbackFor屬性(這是我在放棄事務性測試以支持其他解決方案后在代碼中發現的實際錯誤,目前尚待解釋)。 但這不是重點。 關鍵是– 您真的可以負擔起編寫和維護正在生成錯誤肯定結果的測試,說服您的應用程序正常運行的能力,而事實并非如此?
哦,我是否提到過跨語言測試實際上在這里和那里泄漏,并且不會阻止您修改測試數據庫? 第二次測試失敗,您知道為什么嗎?
@Test
public void shouldStoreReviewInSecondThread() throws Exception {final Book someBook = findAnyExistingBook();executorService.submit(new Callable<Review>() {@Overridepublic Review call() throws Exception {return reviewDao.save(new Review("Unicorn", "Excellent!!!1!", someBook));}}).get();
}
@Test
public void shouldNotSeeReviewStoredInPreviousTest() throws Exception {//given//whenfinal Iterable<Review> reviews = reviewDao.findAll();//thenassertThat(reviews).isEmpty();
}
線程再次陷入困境。 當您嘗試在顯然已提交的后臺線程中進行外部事務處理后進行清理時,它會變得更加有趣。 自然的地方是在@After方法中刪除創建的Review。 但是@After是在同一測試事務中執行的,因此清理將…回滾。
當然,我并不是在抱怨我最喜歡的應用程序堆棧弱點。 我在這里提供解決方案和提示。 我們的目標是完全擺脫事務測試,僅依賴于應用程序事務。 這將有助于我們避免上述所有問題。 顯然,我們不能放棄測試的獨立性和可重復性功能。 每個測試必須在同一數據庫上工作才能可靠。 首先,我們將把JUnit測試轉換為ScalaTest。 為了獲得Spring依賴注入支持,我們需要這個微小的特征:
trait SpringRule extends Suite with BeforeAndAfterAll { this: AbstractSuite =>override protected def beforeAll() {new TestContextManager(this.getClass).prepareTestInstance(this)super.beforeAll();}}
現在是時候揭示我的想法了(如果您不耐煩,請在此處查看完整的源代碼 )。 它遠非獨創性或獨創性,但我認為它值得關注。 無需在一個巨大的事務中運行所有內容并將其回滾,只需讓經過測試的代碼在需要和配置的任何地方,任何時間啟動和提交事務即可。 這意味著數據實際上已寫入數據庫,并且持久性與部署后的工作原理完全相同。 哪里有收獲? 每次測試后,我們都必須以某種方式清理混亂……
事實證明它并不那么復雜。 只需轉儲干凈的數據庫,然后在每次測試后將其導入! 轉儲包含在部署和應用程序啟動之后,第一次測試運行之前立即存在的所有表,約束和記錄。 就像備份并從中還原一樣! 看看H2有多簡單:
trait DbResetRule extends Suite with BeforeAndAfterEach with BeforeAndAfterAll { this: SpringRule =>@Resource val dataSource: DataSource = nullval dbScriptFile = File.createTempFile(classOf[DbResetRule].getSimpleName + "-", ".sql")override protected def beforeAll() {new JdbcTemplate(dataSource).execute("SCRIPT NOPASSWORDS DROP TO '" + dbScriptFile.getPath + "'")dbScriptFile.deleteOnExit()super.beforeAll()}override protected def afterEach() {super.afterEach()new JdbcTemplate(dataSource).execute("RUNSCRIPT FROM '" + dbScriptFile.getPath + "'")}}trait DbResetSpringRule extends DbResetRule with SpringRule
SQL轉儲(請參閱H2 SCRIPT命令)執行一次并導出到臨時文件。 然后在每次測試后執行SQL腳本文件。 信不信由你,就是這樣! 我們的測試不再是事務性的(因此,所有Hibernate和多線程的極端情況都已發現并測試了),而我們并沒有犧牲事務性測試設置的簡便性(不需要額外的清理)。 我最終還可以在調試時查看數據庫內容! 這是進行中的先前測試之一:
@RunWith(classOf[JUnitRunner])
@ContextConfiguration(classes = Array[Class[_]](classOf[SpringConfiguration]))
class BookServiceTest extends FunSuite with ShouldMatchers with BeforeAndAfterAll with DbResetSpringRule {@Resourceval bookService: BookService = nullprivate def findAnyExistingBook() = bookService.listBooks(new PageRequest(0, 1)).getContent.headtest("should delete entity and throw an exception") {val someBook = findAnyExistingBook()intercept[OppsException] {bookService.deleteAndThrow(someBook)}bookService findBy someBook.id should be (None)}
}
請記住,這不是一個庫/實用程序,而是一個想法。 對于您的項目,您可能會選擇略有不同的方法和工具,但是總體思路仍然適用:讓您的代碼在與部署后完全相同的環境中運行,然后從備份中清除混亂。 您可以使用JUnit, HSQLDB或任何您喜歡的方法獲得完全相同的結果。 當然,您還可以添加一些巧妙的優化方法-標記或發現未修改數據庫的測試,選擇更快的轉儲,導入方法等。
老實說,還有一些弊端,以下是我腦海中的一些缺點:
- 性能 :盡管這種方法并不總是比回滾事務慢得多(某些數據庫回滾特別慢),但并不明顯,但可以肯定地說。 當然,內存數據庫可能具有一些意外的性能特征,但要為速度變慢做好準備。 但是,我沒有在一個小項目中觀察到每100個測試有巨大的差異(大約10%)。
- 并發性 :您不再可以同時運行測試。 一個線程(測試)所做的更改對其他線程可見,從而使測試執行無法預測。 對于上述性能問題,這甚至變得更加痛苦。
就是這樣。 如果您有興趣,請給這種方法一個機會。 采用您現有的測試基礎可能需要一些時間,但是即使發現一個隱藏的bug也值得,您是不是認為呢? 并且還要注意其他Spring陷阱 。
參考: Spring陷阱: NoBlogDefFound博客上的 JCG合作伙伴 Tomasz Nurkiewicz 認為有害的事務測試 。
- Spring陷阱:代理
- Spring聲明式事務示例
- Spring依賴注入技術的發展
- Spring和AspectJ的領域驅動設計
- Spring 3使用JUnit 4進行測試– ContextConfiguration和AbstractTransactionalJUnit4SpringContextTests
- 使用Spring AOP進行面向方面的編程
- Java教程和Android教程列表
翻譯自: https://www.javacodegeeks.com/2011/12/spring-pitfalls-transactional-tests.html