精通 Grails: 測試 Grails 應用程序

排除 bug,構建可執行文檔 Grails 能夠輕鬆確保您的應用程序從始至終都遠離 bug。這還有另外一個好處,您能夠利用測試代碼生成一組一般是最新的可執行文檔。本月 Grails 專家 Scott Davis 向您展現如何對 Grails 進行測試。 查看本系列更多內容 |   評論: Scott Davis , 主編, AboutGroovy.com 2008 年 10 月 31 日 + 內容 我是測試驅動開發(test-driven development,TDD)的大力支持者。Neal Ford(The Productive Programmer 的做者)說道 「不測試所編寫的代碼就是失職」。Michael Feathers(Working Effectively with Legacy Code 的做者)將 「遺留代碼」 定義爲沒有通過相應測試的任何軟件 — 這代表編寫代碼而不進行測試是一種過期的實踐。我常說每編寫必定數量的生產代碼,就要編寫兩倍的測試代碼。 精通 Grails 還沒有討論 TDD,由於到目前爲止,這個系列主要關注如何利用 Grails 的核心功能。測試基礎設施代碼(不用您編寫的代碼)有必定的價值,但我不多這樣作。我相信 Grails 可以正確地將個人 POGO 呈現爲 XML,或在我調用 trip.save() 時將個人 Trip 保存到數據庫。當您檢查本身編寫的代碼時,測試的真正價值就體現出來了。若是您編寫一個複雜的算法,您應該有一個或多個補充單元測試,確保該算法正常工做。在本文,您將看到 Grails 如何幫助和鼓勵您進行應用程序測試。 編寫第一個測試 在開始測試以前,我將介紹一個新的域類。這個類的一些定製功能必須通過測試才能進入到生產中。輸入 grails create-domain-class HotelStay,如清單 1 所示: 關於本系列 Grails 是一種新型 Web 開發框架,它將常見的 Spring 和 Hibernate 等 Java™ 技術與當前流行的約定優於配置等實踐相結合。Grails 是用 Groovy 編寫的,它能夠提供與遺留 Java 代碼的無縫集成,同時還能夠加入腳本編制語言的靈活性和動態性。學習完 Grails 以後,您將完全改變看待 Web 開發的方式。 清單 1. 建立 HotelStay 類 $ grails create-domain-class HotelStay Environment set to development      [copy] Copying 1 file to /src/trip-planner2/grails-app/domain Created Domain Class for HotelStay      [copy] Copying 1 file to /src/trip-planner2/test/integration Created Tests for HotelStay 從清單 1 能夠看到,Grails 在 grails-app/domain 目錄中爲您建立了一個空的域類。它還在 test/integration 目錄中建立了一個帶有空的 testSomething() 方法的 GroovyTestCase 類(稍後我將進一步講述單元測試和集成測試的區別)。清單 2 展現了一個帶有生成的測試的空 HotelStay 類: 清單 2. 帶有生成的測試的空類 class HotelStay { } class HotelStayTests extends GroovyTestCase {     void testSomething() {     } } GroovyTestCase 是在 JUnit 3.x 單元測試之上的一層 Groovy。若是您熟悉 JUnit TestCase,您確定知道 GroovyTestCase 是如何工做的。對於這兩種狀況,您經過斷言代碼正常工做來測試它們。JUnit 有各類不一樣的斷言方法,包括 assertEquals、assertTrue 和 assertNull 等等。它使您經過編程的方式代表 「我斷言這個代碼按照預期工做」。 爲何是 JUnit 3.x 而不是 4.x? 因爲歷史緣由,GroovyTestCase 就是一個 JUnit 3.x TestCase。當 Groovy 1.0 於 2007 年 1 月發佈時,它支持 Java 1.4 語言結構。它能夠在 Java 1.四、1.5 和 1.6 JVM 上運行,但在語言級別上僅與 Java 1.4 兼容。 接下來 Groovy 的主要發佈版是 1.5,在 2008 年 1 月發佈。Groovy 1.5 支持全部 Java 1.5 語言特性,好比泛型、靜態導入、for/in 循環和註釋(後者最值得討論)。不過 Groovy 1.5 仍然能夠在 Java 1.4 JVM 上運行。Groovy 開發團隊許諾全部 Groovy 1.x 版本都將與 Java 1.4 保持向後兼容性。當 Groovy 2.x 發佈時(多是 2009 年底或 2010 年),它將不支持 Java 1.4。 所以,這些與 GroovyTestCase 打包的 JUnit 版本有什麼關係呢?JUnit 4.x 引入了一些註釋,好比 @test、@before 和 @after。儘管這些新特性很是有趣,但 JUnit 3.x 仍然是 GroovyTestCase 向後兼容 Java 1.4 的基礎。 這就是說,您徹底可使用 JUnit 4.x(參見 參考資料 得到 Groovy 站點相關文檔的連接)。引入其餘使用註釋和 Java 5 語言特性的測試框架是徹底有可能的(參見 參考資料 得到結合使用 TestNG 和 Groovy 的示例)。Groovy 的字節碼與 Java 編程兼容,所以您能夠經過 Groovy 使用任何 Java 測試框架。 將清單 3 中的代碼添加到 grails-app/domain/HotelStay.groovy 和 test/integration/HotelStayTests.groovy: 清單 3. 一個簡單的測試 class HotelStay{   String hotel } class HotelStayTests extends GroovyTestCase {   void testSomething(){     HotelStay hs = new HotelStay(hotel:"Sheraton")     assertEquals "Sheraton", hs.hotel   } } 清單 3 正是我前面提到那種低級 Grails 基礎設施測試。您應該相信 Grails 可以正確執行這個操做,所以這是一個典型的錯誤測試類型。但它容許您編寫最簡單的測試並觀察其運行,實現了本文的目的。 要運行全部測試,請輸入 grails test-app。要僅運行這個測試,請輸入 grails test-app HotelStay(因爲約定優於配置,Tests 後綴能夠省略)。無論輸入哪一個命令,您應該會在命令提示中看到如清單 4 所示的輸出(注意:爲了突出重要的特性,我刪減了許多代碼)。 清單 4. 運行測試時的輸出 $ grails test-app Environment set to test No tests found in test/unit to execute ... ------------------------------------------------------- Running 1 Integration Test... Running test HotelStayTests...                     testSomething...SUCCESS Integration Tests Completed in 253ms ------------------------------------------------------- Tests passed. View reports in /src/trip-planner2/test/reports 這裏發生了 4 件重要的事情: 能夠看到,environment 被設置爲 test。這意味着 conf/DataSource.groovy 文件中的 test 塊的數據庫設置已生效。 test/unit 中的腳本已運行。您還沒有編寫任何單元測試,因此不能找到任何單元測試,這並不奇怪。 test/integration 中的腳本已經運行。您能夠看到 HotelStayTests.groovy 腳本的輸出 — 它的旁邊有個很大的 SUCCESS。 這個腳本向您展現一組報告。 若是您在 Web 瀏覽器中打開 /src/trip-planner2/test/reports/html/index.html,應該會看到一個關於全部已運行的測試的報告。如圖 1 所示。 圖 1. JUnit 頂級彙總報告 JUnit 頂級彙總報告 若是您單擊 HotelStayTests 連接,應該會看到 doSomething() 測試,如圖 2 所示: 圖 2. JUnit 類級報告 JUnit 類級報告 若是測試意外失敗,命令提示輸出和 HTML 報告(如圖 3 所示) 將通知您: 圖 3. 失敗的 JUnit 測試 失敗的 JUnit 測試 回頁首 編寫第一個有價值的測試 以上是第一個正常運行的簡單測試,接下來將展現一個更加實用的 測試示例。假設您的 HotelStay 類有兩個字段:Date checkIn 和 Date checkOut。根據一個用戶情景,toString 方法的輸出應該像這樣:Hilton (Wednesday to Sunday)。經過 java.text.SimpleDateFormat 類,獲取正確格式的日期很是簡單。您應該爲此編寫一個測試,但不需驗證 SimpleDateFormat 是否正確工做。您的測試作兩件事情:它驗證 toString 方法是否按照預期運行;它證實您是否知足用戶情景。 單元測試是可執行的文檔 用戶需求經常是桌面上的某些文檔。做爲開發人員,您應該將這些需求轉換成有效的軟件。 需求文檔的問題是:在進行實際軟件開發時它一般已通過時。它不是能夠隨着軟件的發展而變化的 「活動文檔」。工件 一詞完美地描述了這種狀況 — 文檔描述軟件最初的、歷史性的任務是什麼,而不是當前實現要作什麼。 要想準備一組全面的、優秀的測試,僅僅保持代碼沒有 bug 是不夠的。這樣的測試有一個附帶的好處,即您能夠獲得 「可執行的文檔」:用代碼表示活動的、不斷變化的項目需求。若是將測試映射到需求,則能夠和用戶共享某些內容。您必須保證代碼的健全,保證知足了用戶的需求。將這個可執行文檔與 CruiseControl 等持續集成服務器(持續反覆地運行測試的服務器)相結合,就能夠獲得一個安全保障機制,它保證新特性不會對本來良好的軟件形成損害。 行爲驅動的開發(Behavior-Driven Development,BDD)徹底採用了可執行文檔的想法。easyb 是一個用 Groovy 編寫的 BDD,它容許您將測試編寫成用戶和開發人員均可以閱讀的用戶需求(參見 參考資料)。若是一些用戶思想比較前衛,寧願放棄 Microsoft® Word(例如),easyb 能夠排除全部過期的需求文檔。所以,項目需求從一開始就是可執行的。 將清單 5 中的代碼輸入到 HotelStay.groovy 和 HotelStayTests.groovy: 清單 5. 使用 assertToString import java.text.SimpleDateFormat class HotelStay {   String hotel   Date checkIn   Date checkOut      String toString(){     def sdf = new SimpleDateFormat("EEEE")     "${hotel} (${sdf.format(checkIn)} to ${sdf.format(checkOut)})"   }  } import java.text.SimpleDateFormat class HotelStayTests extends GroovyTestCase {     void testSomething(){...}     void testToString() {       def h = new HotelStay(hotel:"Hilton")       def df = new SimpleDateFormat("MM/dd/yyyy")       h.checkIn = df.parse("10/1/2008")       h.checkOut = df.parse("10/5/2008")       println h       assertToString h, "Hilton (Wednesday to Sunday)"     } } 輸入 grails test-app 驗證第二個測試是否經過。 testToString 方法使用了新的斷言方法之一 —assertToString— 它由 GroovyTestCase 引入。使用 JUnit assertEquals 方法確定會得到相同的結果,可是 assertToString 的表達能力更強。測試方法的名稱和最終的斷言清楚地代表了這個測試的目的(參見 參考資料 得到一個連接,它列出了 GroovyTestCase 支持的全部斷言,包括 assertArrayEquals、assertContains 和 assertLength)。 回頁首 添加控制器和視圖 到目前爲止,您一直以編程的方式與 HotelStay 域類交互。添加一個 HotelStayController,如清單 6 所示,它使您可以在 Web 瀏覽器上使用該類: 清單 6. HotelStayController 源代碼 class HotelStayController {   def scaffold = HotelStay } 您應該對 create 表單進行仔細的 UI 調試。默認狀況下,日期字段包括 day、month、year、hours 和 minutes,如圖 4 所示: 圖 4. 默認顯示日期和時間 默認顯示日期和時間 在這裏,忽略日期字段的時間戳部分是安全的。輸入 grails generate-views HotelStay。要建立圖 5 所示的通過修改的 UI,請將 precision="day" 添加到 views/hotelStay/create.gsp 和 views/hotelStay/edit.gsp 中的 <g:datePicker> 元素: 圖 5. 僅顯示日期 僅顯示日期 有了運行在 servlet 容器中的活動的、有效的 HotelStay 以後,就要開始討論測試了:單元測試仍是集成測試? 回頁首 對比單元測試和集成測試 如我前面所述,Grails 支持兩種基本類型的測試:單元測試和集成測試。這二者之間沒有語法區別 — 它們都是用相同的斷言寫的 GroovyTestCase。它們的區別在於語義。單元測試孤立地測試類,而集成測試在一個完整的運行環境中測試類。 坦白地說,若是您想將全部的 Grails 測試都編寫成集成測試,則恰好符合個人想法。全部 Grails create-* 命令都生成相應的集成測試,因此不少人都使用現成的集成測試。正如稍後看到的同樣,不少測試須要在完整的運行環境中進行,所以默認使用集成測試是很好的選擇。 若是您想測試一些非核心 Grails 類,則適合使用單元測試。要建立一個單元測試,請輸入 grails create-unit-test MyTestUnit。由於測試腳本不是在不一樣的包中建立的,因此單元測試和集成測試的名稱應該是唯一的。若是不是這樣的話,將會收到清單 7 所示的錯誤消息: 清單 7. 單元測試和集成測試同名時收到的錯誤消息 The sources /src/trip-planner2/test/integration/HotelStayTests.groovy and    /src/trip-planner2/test/unit/HotelStayTests.groovy are    containing both a class of the name HotelStayTests. @ line 3, column 1.    class HotelStayTests extends GroovyTestCase {    ^ 1 error 由於集成測試默認使用後綴 Tests,因此我在全部單元測試上都使用後綴 UnitTests,避免混淆。 回頁首 爲簡單的驗證錯誤消息編寫測試 下一個用戶場景說明 hotel 字段不能留空。這很容易經過內置的 Grails 驗證框架來實現。將一個 static constraints 塊添加到 HotelStay,如清單 8 所示: 清單 8. 將一個 static constraints 塊添加到 HotelStay class HotelStay {   static constraints = {     hotel(blank:false)     checkIn()     checkOut()   }     String hotel   Date checkIn   Date checkOut     //the rest of the class remains the same } 輸入 grails run-app。若是您嘗試在留空 hotel 字段的狀況下建立一個 HotelStay,將收到如圖 6 所示的錯誤消息: 圖 6. 空字段的默認錯誤消息 空字段的默認錯誤消息 我敢保證您的用戶會喜歡這個特性,但對默認的錯誤消息還不是很滿意。假設他們稍微改動了一下用戶場景:hotel 字段不能留空;若是留空,錯誤消息會提示 「Please provide a hotel name」。 如今您已經添加了一些定製代碼 — 儘管它就像一個定製的 String 那麼簡單 — 接下來應該添加測試了(固然,編寫一個驗證用戶場景的完整性的測試 — 儘管不涉及到定製代碼 — 也是徹底能夠接受的。 打開 grails-app/i18n/messages.properties 並添加 hotelStay.hotel.blank=Please provide a hotel name。嘗試在瀏覽器中提交一個空 hotel。這時您將看到本身的定製消息,如圖 7 所示: 圖 7. 顯示定製的驗證錯誤消息 顯示定製的驗證錯誤消息 向 HotelStayTests.groovy 添加一個新測試,檢驗對空字段的驗證是否有效,如清單 9 所示: 清單 9. 測試驗證錯誤 class HotelStayTests extends GroovyTestCase {   void testBlankHotel(){     def h = new HotelStay(hotel:"")     assertFalse "there should be errors", h.validate()     assertTrue "another way to check for errors after you call validate()", h.hasErrors()   }   //the rest of the tests remain unchanged } 在生成的控制器中,您已經看到添加到域類中的 save() 方法。在這裏,我原本也能夠調用 save(),但事實上我並不想把新的類保存到數據庫。我只關注驗證是否發生。由 validate() 方法來完成這個任務。若是驗證失敗,則返回 false。如驗證成功,則返回 true。 hasErrors() 是另外一個頗有價值的測試方法。在調用 save() 或 validate() 以後,hasErrors() 容許您查看驗證錯誤。 清單 10 是通過擴展的 testBlankHotel(),它引入了其餘一些頗有用的驗證方法: 清單 10. 驗證錯誤的高級測試 class HotelStayTests extends GroovyTestCase {   void testBlankHotel(){    def h = new HotelStay(hotel:"")    assertFalse "there should be errors", h.validate()    assertTrue "another way to check for errors after you call validate()", h.hasErrors()       println "\nErrors:"    println h.errors ?: "no errors found"           def badField = h.errors.getFieldError('hotel')    println "\nBadField:"    println badField ?: "hotel wasn't a bad field"    assertNotNull "I'm expecting to find an error on the hotel field", badField    def code = badField?.codes.find {it == 'hotelStay.hotel.blank'}    println "\nCode:"    println code ?: "the blank hotel code wasn't found"    assertNotNull "the blank hotel field should be the culprit", code   } } 肯定類沒有經過驗證以後,您能夠調用 getErrors() 方法(在這裏,藉助 Groovy 簡潔的 getter 語法,它被縮略爲 errors),返回一個 org.springframework.validation.BeanPropertyBindingResult。就像 GORM 與 Hibernate 相比是一個瘦 Groovy 層同樣,Grails 驗證只不過是一個簡單的 Spring 驗證。 調用 println 的結果不會在命令行上顯示,但它們出如今 HTML 報告中,如圖 8 所示: 圖 8. 查看測試的 println 輸出 查看測試的 println 輸出 在 HotelStayTests 報告的右下角單擊 System.out 連接。 清單 10 中給人親切感受的 Elvis 操做符(轉過臉來 — 看見他向後梳起的髮型和那雙眼睛嗎?)是一個縮略的 Groovy 三元操做符。若是 ?: 左邊的對象爲 null,將使用右邊的值。 將 hotel 字段更改成 "Holiday Inn" 並從新運行測試。您將在 HTML 報告中看到另外一個 Elvis 輸出,如圖 9 所示: 圖 9. 測試輸出中的 Elvis 測試輸出中的 Elvis 看見 Elvis 以後,不要忘記清空 hotel 字段 — 若是您不但願留下中斷的測試的話。 若是仍然顯示關於 checkIn 和 checkOut 的驗證錯誤,您沒必要擔憂。就這個測試而言,您徹底能夠忽略它們。可是這代表您不該該僅測試錯誤是否出現 — 您應該確保特定的 錯誤被拋出。 注意,我沒有斷言定製錯誤消息的確切文本。爲何我上一次關注匹配的字符串(測試 toString 的輸出時)而這一次沒有關注?toString 方法的定製輸出即是上一個測試的目的。這一次,我更關心的是肯定驗證代碼的執行,而不是 Grails 是否正確呈現消息。這代表測試更像一門藝術,而不是科學(若是我想驗證準確的消息輸出,則應該使用 Web 層測試工具,好比 Canoo WebTest 或 ThoughtWorks Selenium)。 回頁首 建立和測試定製驗證 如今,應該處理下一個用戶場景了。您須要確保 checkOut 日期發生在 checkIn 日期以後。要解決這個問題,您須要編寫一個定製驗證。編寫完以後,要驗證它。 將清單 11 中的定製驗證代碼添加到 static constraints 塊: 清單 11. 一個定製的驗證 class HotelStay {   static constraints = {     hotel(blank:false)     checkIn()     checkOut(validator:{val, obj->       return val.after(obj.checkIn)     })   }     //the rest of the class remains the same } val 變量是當前的字段。obj 變量表示當前的 HotelStay 實例。Groovy 將 before() 和 after() 方法添加到全部 Date 對象,因此這個驗證僅返回 after() 方法調用的結果。若是 checkOut 發生在 checkIn 以後,驗證返回 true。不然,它返回 false 並觸發一個錯誤。 如今,輸入 grails run-app。確保不能建立一個 checkOut 日期早於 checkIn 日期的新 HotelStay 實例。如圖 10 所示: 圖 10. 默認的定製驗證錯誤消息 默認的定製驗證錯誤消息 打開 grails-app/i18n/messages.properties,並向 checkOut 字段添加一個定製驗證消息:hotelStay.checkOut.validator.invalid=Sorry, you cannot check out before you check in。 保存 messages.properties 文件並嘗試保存有缺陷的 HotelStay。您將看到如清單 11 所示的錯誤消息: 清單 11. 定製驗證錯誤消息 定製驗證錯誤消息 如今應該編寫測試了,如清單 12 所示: 清單 12. 測試定製的驗證 import java.text.SimpleDateFormat class HotelStayTests extends GroovyTestCase {   void testCheckOutIsNotBeforeCheckIn(){     def h = new HotelStay(hotel:"Radisson")     def df = new SimpleDateFormat("MM/dd/yyyy")     h.checkIn = df.parse("10/15/2008")     h.checkOut = df.parse("10/10/2008")       assertFalse "there should be errors", h.validate()     def badField = h.errors.getFieldError('checkOut')     assertNotNull "I'm expecting to find an error on the checkOut field", badField     def code = badField?.codes.find {it == 'hotelStay.checkOut.validator.invalid'}     assertNotNull "the checkOut field should be the culprit", code                   } } 回頁首 測試定製的 TagLib 接下來是最後一個須要處理的用戶場景。您已經在 create 和 edit 視圖中成功地處理了 checkIn 和 checkOut 的時間戳 部分,但它在 list 和 show 視圖中仍然是錯誤的,如圖 12 所示: 圖 12. 默認的 Grails 日期輸入(包括時間戳) 默認的 Grails 日期輸入(包括時間戳) 最簡單的解決辦法是定義一個新的 TagLib。您能夠利用 Grails 已經定義的 <g:formatDate> 標記,但建立一個本身的定製標記也很容易。我想建立一個能夠以兩種方式使用的 <g:customDateFormat> 標記。 一種形式的 <g:customDateFormat> 標記打包一個 Date,並接受一個接受任何有效 SimpleDateFormat 模式的定製格式屬性: <g:customDateFormat format="EEEE">${new Date()}</g:customDateFormat> 由於大多數用例都以美國的 「MM/dd/yyyy」 格式返回日期,因此若是沒有特別指定,我將採用這種格式: <g:customDateFormat>${new Date()}</g:customDateFormat> 如今,您已經知道了每一個用戶場景的需求,那麼請輸入 grails create-tag-lib Date(如清單 13 所示),以建立一個全新的 DateTagLib.groovy 文件和一個相應的 DateTagLibTests.groovy 文件: 清單 13. 建立一個新的 TagLib $ grails create-tag-lib Date [copy] Copying 1 file to /src/trip-planner2/grails-app/taglib Created TagLib for Date [copy] Copying 1 file to /src/trip-planner2/test/integration Created TagLibTests for Date 將清單 14 中的代碼添加到 DateTagLib.groovy: 清單 14. 建立定製的 TagLib import java.text.SimpleDateFormat class DateTagLib {   def customDateFormat = {attrs, body ->     def b = attrs.body ?: body()     def d = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").parse(b)         //if no format attribute is supplied, use this     def pattern = attrs["format"] ?: "MM/dd/yyyy"     out << new SimpleDateFormat(pattern).format(d)   } } TagLib 接受屬性形式的簡單的 String 值和標記體,並將一個 String 發送到輸出流。因爲您將使用這個定製標記封裝未格式化的 Date 字段,因此須要兩個 SimpleDateFormat 對象。輸入對象讀入一個與 Date.toString() 調用的默認格式相匹配的 String。當將其解析爲適當的 Date 對象以後,您就能夠建立第二個 SimpleDateFormat 對象,以便以另外一種格式的 String 將它傳回。 使用新的 TagLib 在 list.gsp 和 show.gsp 中封裝 checkIn 和 checkOut 字段。如清單 15 所示: 清單 15. 使用定製的 TagLib <g:customDateFormat>${fieldValue(bean:hotelStay, field:'checkIn')}</g:customDateFormat> 輸入 grails run-app,而後訪問 http://localhost:9090/trip/hotelStay/list,檢查實際使用中的定製 TagLib,如圖 13 所示: 圖 13. 使用定製 TagLib 的數據輸出 使用定製 TagLib 的數據輸出 如今,編寫清單 16 中的幾個測試,用來檢查 TagLib 是否按照預期工做: 清單 16. 測試定製的 TagLib import java.text.SimpleDateFormat class DateTagLibTests extends GroovyTestCase {     void testNoFormat() {       def output =          new DateTagLib().customDateFormat(format:null, body:"2008-10-01 00:00:00.0")       println "\ncustomDateFormat using the default format:"       println output             assertEquals "was the default format used?", "10/01/2008", output     }     void testCustomFormat() {       def output =          new DateTagLib().customDateFormat(format:"EEEE", body:"2008-10-01 00:00:00.0")       assertEquals "was the custom format used?", "Wednesday", output     } } 回頁首 結束語 到目前爲止,您已經編寫了幾個測試,並看到了用它們測試 Grails 組件是多麼簡單!可是您能夠繼續開拓,不斷取得進步,這會讓您對工做更加自信。將本身的測試和用戶場景匹配起來有這樣的好處:您將擁有一組永遠保持最新的可執行文檔。 在下一篇文章中,我將重點討論 JavaScript Object Notation (JSON)。Grails 具備出色的開箱即用的 JSON 支持。您將瞭解如何經過控制器生成 JSON,以及如何在 GSP 中使用它。在此期間,享受精通 Grails 帶來的樂趣吧。
相關文章
相關標籤/搜索