2016年9月26日

ScalaTest

ScalaTest 可以說是 scala 測試的王者,主要原因是它提供了七種 Test Spec,其中最基本的就是 FunSpec,有兩種不同的 assertion dialects,可以依照你自己的喜好,決定使用哪一種寫法,另外 ScalaTest 也跟 SBT 整合在一起,可以直接由 SBT 自動化進行測試。

如何使用

在專案的 build.sbt 中,增加 ScalaTest 的 libraryDependencies。

build.sbt

libraryDependencies ++= Seq(
  "org.scalactic" %% "scalactic" % "2.2.6",
  "org.scalatest" %% "scalatest" % "2.2.6" % "test"
)

如果要進行 Spark 的測試,也要加上 Spark 的 library,以下是我們的 build.sbt 內容。

name := "sparktest"

version := "1.0"

sbtPlugin := true

scalaVersion := "2.10.6"

val sparkVersion = "1.6.1"

libraryDependencies ++= Seq(
  "ch.qos.logback" % "logback-core" % "0.9.26",
  "ch.qos.logback" % "logback-classic" % "0.9.26",

  "org.slf4j" % "slf4j-api" % "1.6.1",

  "org.scalactic" %% "scalactic" % "2.2.6",
  "org.scalatest" %% "scalatest" % "2.2.6" % "test",

  // kafka
  "org.apache.kafka" %% "kafka" % "0.9.0.1",
  "org.apache.kafka" % "kafka-clients" % "0.9.0.1",

  // spark
  "org.apache.spark" %% "spark-core" % sparkVersion % "provided",
  "org.apache.spark" %% "spark-hive" % sparkVersion % "provided",
  "org.apache.spark" %% "spark-mllib" % sparkVersion % "provided",
  "org.apache.spark" %% "spark-streaming" % sparkVersion % "provided",
  "org.apache.spark" %% "spark-streaming-kafka" % sparkVersion,

  // geoip2-2.6.0
  "com.maxmind.geoip2" % "geoip2" % "2.6.0",
  "com.maxmind.db" % "maxmind-db" % "1.2.1"
)

// 因為 spark 的 libraryDependencies 裡面用到的 library 有衝突,必須加上這一行將衝突解除
dependencyOverrides ++= Set(
  "com.fasterxml.jackson.core" % "jackson-databind" % "2.4.4"
)

ivyScala := ivyScala.value map {
  _.copy(overrideScalaVersion = true)
}

assemblyMergeStrategy in assembly := {
  case m if m.toLowerCase.endsWith("manifest.mf") => MergeStrategy.discard
  case m if m.startsWith("META-INF") => MergeStrategy.discard
  case PathList("javax", "servlet", xs@_*) => MergeStrategy.first
  case PathList("org", "apache", xs@_*) => MergeStrategy.first
  case "about.html" => MergeStrategy.rename
  case "reference.conf" => MergeStrategy.concat
  case _ => MergeStrategy.first
}

Matchers

ScalaTest 包含了兩種 Matchers: MustMatchers 及 ShouldMatchers,以下用 FunSpec 為例,說明 ShouldMatchers,而MustMatchers 寫法跟 ShouldMatchers 類似。

  1. Simple Matcher
  2. String Matcher
  3. Relational Operator Matcher
  4. Floating-point Macher
  5. Reference Macher
  6. Iterable Macher
  7. Seq 與 Traversable Matchers
  8. Map Matcher
  9. Compound Matcher
  10. Property Matcher
  11. Java Collection Matcher
import org.scalatest.{FunSpec, Matchers}

class TestFunSpec extends FunSpec with Matchers{
  describe("Using all should matchers") {

    // Simple Matcher
    //  驗證的資料要放在 () 裡面
    //  be 可以換成 equal
    //  be 不能換成 == 或是 !=
    it("has simple matchers") {
      val list = 2 :: 4 :: 5 :: Nil
      list.size should be(3)
      list.size should equal(3)
    }

    // String Matcher
    it("has string matchers") {
      val string = """I fell into a burning ring of fire.
         I went down, down, down and the flames went higher"""
      string should startWith("I fell")
      string should endWith("higher")
      string should not endWith "My favorite friend, the end"
      string should include("down, down, down")
      string should not include ("Great balls of fire")

      // 以 regex 作為判斷依據
      string should startWith regex ("I.fel+")
      string should endWith regex ("h.{4}r")
      string should not endWith regex("\\d{5}")
      string should include regex ("flames?")

      string should fullyMatch regex ("""I(.|\n|\S)*higher""") //regex in triple quotes
    }

    // Relational Operator Matcher
    it("has <, >, <=, >= matchers") {
      val answerToLife = 42
      answerToLife should be < (50)
      answerToLife should be > (3)
      answerToLife should be <= (100)
      answerToLife should be >= (0)

      answerToLife shouldNot be <= (0)
    }

    // Floating-point Macher
    //  scala 的 FP 運算,會有誤差值
    //   在 ScalaTest 提供了 +- 的方式,設定允許的誤差範圍
    it("has checking floating point imperfections") {
      (4.0 + 1.2) should be(5.2)
      (0.9 - 0.8) should be(0.1 +- .01)
      (0.4 + 0.1) shouldNot be (40.00 +- .30)
    }

    // Reference Macher
    it("has object reference checker") {
      val A:String = "Testing String A"
      val B:String = A
      val C:String = "Testing String C"

      A should be theSameInstanceAs(B)
      C shouldNot be theSameInstanceAs(B)
    }

    // Iterable Matcher
    it("has methods for iterable") {
      // 'empty 是 scala 的特殊符號
      List() should be('empty)
      List() shouldBe 'empty
      List() shouldBe 'traversableAgain
      8 :: 6 :: 7 :: 5 :: 3 :: 0 :: 9 :: Nil should contain(7)
    }

    // Seq 與 Traversable Matchers
    it("has methods for seq") {
      // 提供了 length, size method for Seq or Traversable
      (1 to 9).toList should have length (9)
      (1 to 9).toList should have size (9)
    }
    it("has methods for traversable") {
      (20 to 60 by 2).toList should have length (21)
      (20 to 60 by 2).toList should have size (21)
    }

    // Map Matcher
    it("has methods for map") {
      // 檢查 Map 裡面是否有某個 key 或 value
      val map = Map("Jimmy Page" -> "Led Zeppelin", "Sting" -> "The Police", "Aimee Mann" -> "Til\' Tuesday")
      map should contain key ("Sting")
      map should contain value ("Led Zeppelin")
      map shouldNot contain key("Brian May")
    }

    // Compound Matcher
    it("has compound and and or") {
      val redHotChiliPeppers = List("Anthony Kiedis", "Flea", "Chad Smith", "Josh Klinghoffer")

      // 將 assert 條件用 and, or 串接起來
      redHotChiliPeppers should (contain("Anthony Kiedis") and
        (not contain ("John Frusciante")
          or contain("Dave Navarro")))

      redHotChiliPeppers shouldNot (contain ("The Edge") or contain ("Kenny G"))

      //redHotChiliPeppers shouldNot contain "The Edge" or contain "Kenny G"

      var total = 3
      redHotChiliPeppers shouldNot (contain ("The Edge") or contain {total += 6; "Kenny G"})
      total should be (9)

      val gorillaz:List[String] = null
      gorillaz should be (null)
    }

    // Property Matcher
    it("has a way to asset an object's property using getter method") {
      class Album(val name:String, val year:Int)

      val album = new Album("Ghost Singer", 2016)

      album should have (
        'name ("Ghost Singer"),
        'year (2016)
      )
    }

    // Java Collection Matcher
    it("has method for java collections") {
      import java.util.{ArrayList => JArrayList, HashMap => JHashMap, List => JList, Map => JMap}

      val jList: JList[Int] = new JArrayList[Int](20)
      jList.add(3)
      jList.add(6)
      jList.add(9)

      val emptyJList: JList[Int] = new JArrayList[Int]()

      emptyJList should be('empty)
      jList should have length (3)
      jList should have size (3)
      jList should contain(6)
      jList shouldNot contain (10)

      val backupBands: JMap[String, String] = new JHashMap()
      backupBands.put("Joan Jett", "Blackhearts")
      backupBands.put("Tom Petty", "Heartbreakers")

      backupBands should contain key ("Joan Jett")
      backupBands should contain value ("Heartbreakers")

      backupBands shouldNot contain key("John Lydon")
    }

  }
}

MustMatchers

ShouldMatchers 可以用 MustMatchers 取代,但不能同時並存,語法就是把 should 換成 must,大部分的文件都是使用 should。

import org.scalatest.{FunSpec, MustMatchers}

class TestFunSpecMustMatcher extends FunSpec with MustMatchers {
  describe("Using all should matchers") {
    // MustMatcher
    it("replace should to must") {
      val list = 2 :: 4 :: 5 :: Nil
      list.size must be(3)
      val string = """I fell into a burning ring of fire.
I went down, down, down and the flames went higher"""
      string must startWith regex ("I.fel+")
      string must endWith regex ("h.{4}r")

      val answerToLife = 42
      answerToLife must be < (50)
      answerToLife mustNot be >(50)


      class Artist(val firstname:String, val lastname:String)
      val garthBrooks = new Artist("Garth", "Brooks")
      val chrisGaines = garthBrooks
      val debbieHarry = new Artist("Debbie", "Harry")

      garthBrooks must be theSameInstanceAs (chrisGaines)
      (0.9 - 0.8) must be(0.1 +- .01)

      List() must be('empty)
      1 :: 2 :: 3 :: Nil must contain(3)
      (1 to 9).toList must have length (9)
      (20 to 60 by 2).toList must have size (21)
      val map = Map("Jimmy Page" -> "Led Zeppelin", "Sting" -> "The Police",
        "Aimee Mann" -> "Til\' Tuesday")
      map must contain key ("Sting")
      map must contain value ("Led Zeppelin")
      map must not contain key("Brian May")
      val redHotChiliPeppers = List("Anthony Kiedis", "Flea", "Chad Smith",
        "Josh Klinghoffer")
      redHotChiliPeppers must (contain("Anthony Kiedis") and
        (not contain ("John Frusciante")
          or contain("Dave Navarro")))
    }
  }
}

Exception Handling

利用 thrownBy 或是 intercept 語法,在 FunSpec 使用 Should Matcher 測試 Exception 的驗證

    // Excpetion Handling
    it("has exception handler") {
      class Album(val name: String, val year: Int) {
        if (year <= 2000) throw new IllegalArgumentException("Album year is required")
      }

      the[IllegalArgumentException] thrownBy (new Album("The Joy of Listening to Nothing",
        0)) should have message "Album year is required"

      intercept[IllegalArgumentException] {
        new Album("The Joy of Listening to Nothing", 0)
      }
    }

    it("should produce NoSuchElementException when head is invoked") {
      intercept[NoSuchElementException] {
        Set.empty.head
      }
    }

Informers

Informer 就像是 ScalaTest 中的 debug message,可以在測試程式的任何地方,輸出一些跟這個測試相關的訊息,只要呼叫 info(String) 就可以了。

// Informers
  describe("An Album") {
    class Album(val name: String, val year: Int) {
      if (year <= 0) throw new IllegalArgumentException("Album year is required")
    }

    it("can create an album") {
      val album = new Album("Thriller", 1981)
      info("Test the album name should be Thriller")
      album.name should be("Thriller")
    }
  }

GivenWhenThen

任何一個測試過程,都可以被描述為 Given ... When ... Then。

Given相當於所給的前置條件

When相當於產生了某個動作或處於某種條件下

Then表示前面兩個條件產生的結果

import org.scalatest.{GivenWhenThen, ShouldMatchers, FunSpec}

class TestAlbumSpec extends FunSpec with ShouldMatchers with GivenWhenThen {

  class Album(val name: String, val year: Int, val artist: Artist) {
    if (year <= 0) throw new IllegalArgumentException("Album year is required")
  }

  class Artist(val firstName: String, val lastName: String)

  describe("An Album") {
    it("can add an Artist to the album at construction time") {

      Given("The album Thriller by Michael Jackson")
      val album = new Album("Thriller", 1981, new Artist("Michael", "Jackson"))

      When("the album\'s artist is obtained")
      val artist = album.artist

      Then("the artist obtained should be an instance of Artist")

      artist.isInstanceOf[Artist] should be(true)

      And("the artist's first name and last name should be Michael Jackson")

      artist.firstName should be("Michael")
      artist.lastName should be("Jackson")
    }
  }
}

以下為測試的結果

[info] TestAlbumSpec:
[info] An Album
[info] - can add an Artist to the album at construction time
[info]   + Given The album Thriller by Michael Jackson
[info]   + When the album's artist is obtained
[info]   + Then the artist obtained should be an instance of Artist
[info]   + And the artist's first name and last name should be Michael Jackson
[info] Run completed in 209 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.

Pending Test 待測試, 未完成的測試, TODO

當我們在 test body 裡面加上 pending 註記時,就表示這是一個需要實作,還沒有完成的測試項目。

    it("can be a pending test") {
      info("pending 可以一直放在 test body 的最後面,代表這還沒完成")

      pending
    }

Ignoring Test

正式環境的程式碼,有可能隨著時間持續的修改,而造成測試的程式碼過時,還沒辦法修改好,無法驗證新版的 production code,寫法就是將 it 換成 ignore 就可以了,ScalaTest 會暫時先忽略這個測試項目。

    ignore("can add a Producer to an album at construction time") {
      new Album("Breezin\'", 1976, new Artist("George", "Benson"))
      //TODO: Figure out the implementation of an album producer
    }

Tagging

可以將測試項目加上 Tag,就可以分組進行測試,Tagging 可以在以下這些狀況使用:

  1. 想跳過某些很費時的測試
  2. 某些測試是檢查一些相關的功能,需要一起執行
  3. 你想給測試分成單元測試、綜合測試、驗收測試等分類時
    object TestTag extends Tag("TestTag")
    object DevTag extends Tag("DevTag")
  
    it("can add multiple tags", TestTag, DevTag) {
      info("test TestTag, DevTag")
    }

在 sbt 裡面,目前只有 testOnly 有支援 Tag,可以啟動某個 tag 的測試,或是忽略某個 tag

# 啟動包含 TestTag 的測試
testOnly com.larzio.lzspark.test.scalatest.TestAlbumSpec -- -n TestTag

# 忽略 TestTag 的測試
testOnly com.larzio.lzspark.test.scalatest.TestAlbumSpec -- -l TestTag

Specifications

ScalaTest 的 Spec 指的是不同的測試 style 寫法,某方面來說,代表著自由度,可以自己選擇習慣的語法,但相對地,代表著混亂,因為寫法太多元,造成團隊開發時,程式風格差異太大。

擁抱開放與自由,就必須接納多元與混亂,開放與封閉環境,都有成功的案例。

  1. FunSpec
  2. WordSpec
  3. FeatureSpec
  4. FreeSpec
  5. FlatSpec
  6. JUnitSuite
  7. TestNGSuite

  1. FunSpec

    使用 describe, it

    import org.scalatest.{FunSpec, Tag, GivenWhenThen, ShouldMatchers}
    
    class AlbumFunSpec extends FunSpec with ShouldMatchers with GivenWhenThen {
    
      class Album(val title: String, val year: Int, val acts: Artist*) {
        if (year <= 0) throw new IllegalArgumentException("Album year is required")
      }
    
      class Artist(val firstName: String, val lastName: String)
    
      describe("An Album") {
        it("can add an Artist to the album at construction time", Tag("construction")) {
          Given("The album Thriller by Michael Jackson")
          val album = new Album("Thriller", 1981, new Artist("Michael", "Jackson"))
    
          When("the first act of the album is obtained")
          val act = album.acts.head
    
          Then("the act should be an instance of Artist")
          act.isInstanceOf[Artist] should be(true)
    
          And("the artist's first name and last name should be Michael Jackson")
          val artist = act.asInstanceOf[Artist]
          artist.firstName === "Michael"
          artist.lastName === "Jackson"
          info("This is still pending, since there may be more to accomplish in this test")
          //      pending
        }
    
        ignore("can add a Producer to an album at construction time") {
          //TODO: Add some logic to add a producer.
        }
      }
    }
  2. WordSpec

    使用了 when, should

    import org.scalatest.{WordSpec, ShouldMatchers}
    
    class AlbumWordSpec extends WordSpec with ShouldMatchers {
    
      class Album(val name: String, val year: Int) {
        if (year <= 0) throw new IllegalArgumentException("Album year is required")
      }
    
      "An Album" when {
        "created" should {
          "accept the title, the year, and a Band as a parameter, and be able to read those parameters back" in {
            new Album("Hotel California", 1977)
          }
        }
      }
    
      "An album" should {
        "throw an IllegalArgumentException if there are no acts when created" in {
          intercept[IllegalArgumentException] {
            new Album("The Joy of Listening to Nothing", 0)
          }
          info("The test at this point should still continue")
          info("since we successfully trapped the  exception")
        }
    
        "test with a thousand acts" ignore {
          //Working on a thousand acts
          new Album("One thousand singers", 2010)
        }
      }
    
    }
  3. FeatureSpec

    使用 feature, scenario

    import org.scalatest.{ShouldMatchers, GivenWhenThen, FeatureSpec}
    
    class EAlbum {
      private var on: Boolean = false
    
      def isOn: Boolean = on
    
      def pressPowerButton() {
        on = !on
      }
    }
    
    class AlbumFeatureSpec extends FeatureSpec with ShouldMatchers with GivenWhenThen {
      info("As a EAlbum set owner")
      info("I want to be able to turn the EAlbum on and off")
      info("So I can watch EAlbum when I want")
      info("And save energy when I'm not watching EAlbum")
    
      feature("EAlbum power button") {
        scenario("User presses power button when EAlbum is off") {
    
          Given("a EAlbum set that is switched off")
          val tv = new EAlbum
          assert(!tv.isOn)
    
          When("the power button is pressed")
          tv.pressPowerButton()
    
          Then("the EAlbum should switch on")
          assert(tv.isOn)
        }
    
        scenario("User presses power button when TV is on") {
    
          Given("a EAlbum set that is switched on")
          val tv = new EAlbum
          tv.pressPowerButton()
          assert(tv.isOn)
    
          When("the power button is pressed")
          tv.pressPowerButton()
    
          Then("the EAlbum should switch off")
          assert(!tv.isOn)
        }
      }
    }
  4. FreeSpec

    將 story line 後面加上 - {

    import org.scalatest.FreeSpec
    
    class TestFreeSpec extends FreeSpec {
    
      "A Set" - {
        "when empty" - {
          "should have size 0" in {
            assert(Set.empty.size == 0)
          }
    
          "should produce NoSuchElementException when head is invoked" in {
            intercept[NoSuchElementException] {
              Set.empty.head
            }
          }
        }
      }
    
    }
  5. FlatSpec

    "X should Y", "A must B"

    import org.scalatest.FlatSpec
    
    class TestFlatSpec extends FlatSpec {
      "An empty Set" should " have size 0 " in {
        assert(Set.empty.size == 0)
      }
    
      it should "produce NoSuchElementException when head is invoked" in {
        intercept[NoSuchElementException] {
          Set.empty.head
        }
      }
    }
    
  6. JUnitSuite

    首先要加上 junit library

    libraryDependencies += "junit" % "junit" % "4.12"

    跟 JUnit 的習慣一樣,@Before 是測試準備,@After 是結束測試,@Test 則是每一個測試的 case

    import org.scalatest.junit.JUnitSuite
    import org.junit.{After, Test, Before}
    import org.junit.Assert._
    
    class TestJUnitSuite extends JUnitSuite {
    
      class Album(val title: String, val year: Int, val acts: Artist*) {
        if (year <= 0) throw new IllegalArgumentException("Album year is required")
      }
    
      class Artist(val firstName: String, val lastName: String)
    
      var artist: Artist = _
    
      @Before
      def startUp() {
        artist = new Artist("Kenny", "Rogers")
      }
    
      @Test
      def newAlbum() {
        val album = new Album("Love will turn you around", 1982, artist)
        assertEquals(album.acts.size, 1)
      }
    
      @After
      def shutDown() {
        this.artist = null
      }
    
    }
    
  7. TestNGSuite

    TestNG 風格的測試,首先要加上 library

    libraryDependencies += "org.testng" % "testng" % "6.8.21"

    @DataProvider(name = "provider") 提供測試資料,@Test(dataProvider = "provider") 則是針對 data provider 進行測試

    import org.scalatest.testng.TestNGSuite
    import collection.mutable.ArrayBuilder
    import org.testng.annotations.{Test, DataProvider}
    import org.testng.Assert._
    
    class TestTestNGSuite extends TestNGSuite {
    
      @DataProvider(name = "provider")
      def provideData = {
        val g = new ArrayBuilder.ofRef[Array[Object]]()
        g += (Array[Object]("Heart", 5.asInstanceOf[java.lang.Integer]))
        g += (Array[Object]("Jimmy Buffet", 12.asInstanceOf[java.lang.Integer]))
        g.result()
      }
    
      @Test(dataProvider = "provider")
      def testTheStringLength(n1: String, n2: java.lang.Integer) {
        assertEquals(n1.length, n2)
      }
    
    }

Fixtures

不同的測試可能會有相同的 object dependencies 或是 測試資料,fixture 就是一次建立 subjects 並能重複使用的機制。

  • Anonymous Objects
import org.scalatest.{ShouldMatchers, FunSpec}

class AlbumFixtureSpec extends FunSpec with ShouldMatchers {

  class Album(val name: String, val year: Int) {
    if (year <= 0) throw new IllegalArgumentException("Album year is required")
  }

  // 定義測試資料
  def fixture = new {
    val letterFromHome = new Album("Letter from Home", 1989)
  }

  describe("The Letter From Home Album by Pat Metheny") {
    it("should get the year 1989 from the album") {

      // 取出測試資料
      val album = fixture.letterFromHome
      album.year should be(1989)
    }
  }
}
  • Fixture Traits

    利用 trait 產生匿名物件

import org.scalatest.{ShouldMatchers, FunSpec}

class AlbumFixtureTraitSpec extends FunSpec with ShouldMatchers {

  class Album(val name: String, val year: Int) {
    if (year <= 0) throw new IllegalArgumentException("Album year is required")
  }

  trait AlbumFixture {
    val letterFromHome = new Album("Letter from Home", 1989)
  }

  describe("The Letter From Home Album by Pat Metheny") {
    it("should get the year 1989 from the album") {
      new AlbumFixture {
        letterFromHome.year should be(1989)
      }
    }
  }
}
  • OneInstancePerTest

    以 OneInstancePerTest 讓每個測試都有自己的資料

import org.scalatest.{OneInstancePerTest, ShouldMatchers, FreeSpec}

import scala.collection.mutable.ListBuffer

class AlbumListOneInstancePerTestFreeSpec extends FreeSpec with ShouldMatchers with OneInstancePerTest {

  class Album(val title: String, val year: Int, val acts: Artist*) {
    if (year <= 0) throw new IllegalArgumentException("Album year is required")
  }

  class Artist(val firstName: String, val lastName: String)

  val graceJonesDiscography = new ListBuffer[Album]()

  graceJonesDiscography += (new Album("Portfolio", 1977, new Artist("Grace", "Jones")))
  "Given an initial Grace Jones Discography" - {
    "when an additional two albums are added, then the discography size should be 3" in {
      graceJonesDiscography += (new Album("Fame", 1978, new Artist("Grace", "Jones")))
      graceJonesDiscography += (new Album("Muse", 1979, new Artist("Grace", "Jones")))
      graceJonesDiscography.size should be(3)
    }

    "when one additional album is added, then the discography size should be 2" in {
      // graceJonesDiscography 會先重建,然後再加入一個新的 Album
      graceJonesDiscography += (new Album("Warm Leatherette", 1980, new Artist("Grace", "Jones")))
      graceJonesDiscography.size should be(2)
    }
  }

  "Given an initial Grace Jones Discography " - {
    "when one additional album from 1980 is added, then the discography size should be 2" in {
      graceJonesDiscography += (new Album("Nightclubbing", 1981, new Artist("Grace", "Jones")))
      graceJonesDiscography.size should be(2)
    }
  }
}
  • Before and After

    控制測試前後的行為

import collection.mutable.ListBuffer
import org.scalatest.{BeforeAndAfter, WordSpec, ShouldMatchers}

class AlbumBeforeAndAfterFixtureSpec extends WordSpec with ShouldMatchers with BeforeAndAfter {

  class Album(val title: String, val year: Int, val acts: Artist*) {
    if (year <= 0) throw new IllegalArgumentException("Album year is required")
  }

  class Artist(val firstName: String, val lastName: String)

  val humanLeagueDiscography = new ListBuffer[Album]()
  
  before {
    info("Starting to populate the discography")
    humanLeagueDiscography += (new Album("Dare", 1981, new Artist("Grace", "Jones")))
  }
  
  "A mutable ListBuffer of albums" should {
    "have a size of 3 when two more albums are added to the Human League Discography" in {
      humanLeagueDiscography += (new Album("Hysteria", 1984, new Artist("Grace", "Jones")))
      humanLeagueDiscography += (new Album("Crash", 1986, new Artist("Grace", "Jones")))
      humanLeagueDiscography should have size (3)
    }
    "have a size of 2 when one more album is added to the Human League Discography" in {
      humanLeagueDiscography += (new Album("Romantic", 1990, new Artist("Grace", "Jones")))
      humanLeagueDiscography should have size (2)
    }
  }
  
  after {
    info("Clearing the discography")
    humanLeagueDiscography.clear()
  }
}

References

Testing in Scala

scalatest selecting a style

ScalaTest測試框架

專題:如何寫測試——Spark

ScalaTest學習筆記

Spark com.fasterxml.jackson.module error

2016年9月19日

SBT

SBT 是 scala 上一個通用的 build tool,除了沿用 ivy 進行 library dependency 管理之外,還增加了更多 console 互動的指令,而且可以直接啟動一個新的 interpreter,進行 code testing。

Installation

在 mac 安裝 sbt,可以用 port 或是 brew

port install sbt

brew install sbt

在 windows,就下載 msi 安裝包,直接安裝

如果要自己手動安裝,必須先下載 sbt-launch.jar,參考 Installing sbt manually 的說明,建立 script

> vi ~/bin/sbt
#!/bin/bash
SBT_OPTS="-Xms512M -Xmx1536M -Xss1M -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=256M -Dfile.encoding=UTF8"
java $SBT_OPTS -jar `dirname $0`/sbt-launch.jar "$@"

first simple project test

建立一個 sbttest 目錄,在該目錄中,執行 sbt,會進入 sbt console

> help
    列印 sbt 基本的指令
> tasks
    列出可以使用的 build task
> settings
    列出我們可以修改的 settings
> inspect
    查詢 setting/task 的資訊

列印 scala source folder

> scalaSource
[info] /project/idea/sbttest/src/main/scala

sbt project 的基本結構如下

<build directory>/
    project/            sbt plugins and build help code
    src/
        main/
            scala/      scala source code
            java/       java source code
            resources/  要放在 classpath 但又不需要編譯的資源檔案
        test/
            scala/
            java/
            resources/
    target/
    
    build.sbt           build file

buildscript.sh

#!/bin/bash

mkdir -p project
mkdir -p src/{main,test}/{scala,java,resources}
mkdir -p target

建立兩個檔案

vi build.sbt

name := "sbttest"

version := "1.0"

vi project/build.properties

sbt.version=0.13.7

建立一個 HelloWorld scala source

vi src/main/scala/HelloWorld.scala

object HelloWorld extends App {
    println("Hello, sbt world!")
}

回到 sbt console,執行 compile task,然後就能直接 run

> compile
[info] Updating {file:/project/idea/sbttest/}sbttest...
[info] Resolving org.fusesource.jansi#jansi;1.4 ...
[info] Done updating.
[info] Compiling 1 Scala source to /project/idea/sbttest/target/scala-2.10/classes...
[success] Total time: 2 s, completed 2016/5/12 上午 10:54:37

> run
[info] Running HelloWorld
Hello, sbt world!
[success] Total time: 0 s, completed 2016/5/12 上午 10:55:48

增修以下的檔案

vi src/main/scala/models.scala

case class Product(id: Long, 
                  attributes: Seq[String])
case class BuyerPreferences(attributes: Seq[String])

vi src/main/scala/logic.scala

object Logic {
  // 判斷是否有吻合 buyer 的喜好特徵
  def matchLikelihood(product: Product, buyer: BuyerPreferences): Double = {
    val matches = buyer.attributes map { attribute =>
      product.attributes contains attribute
    }
    val nums = matches map { b => if(b) 1.0 else 0.0 }
    if (nums.length > 0) nums.sum / nums.length else 0.0
  }
}

在 sbt 中,可以直接以 console 指令進入 scala interpreter

> console
[info] Starting scala interpreter...
[info]
Welcome to Scala version 2.10.4 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_92).
Type in expressions to have them evaluated.
Type :help for more information.

scala>

可以直接使用剛剛編譯的 scala 物件

// 產生 Product p1
scala> val p1 = Product(id=100, attributes = Seq("female", "kid-friendly"))
p1: Product = Product(100,List(female, kid-friendly))

// 產生 BuyerPreferences
scala> val prefs = BuyerPreferences(List("male", "kid-friendly"))
prefs: BuyerPreferences = BuyerPreferences(List(male, kid-friendly))

// 產生 Product p2
scala> val p2 = Product(id=110, attributes = Seq("male", "kid-friendly"))
p2: Product = Product(110,List(male, kid-friendly))

// 檢查 p1 的 attributes 是否有吻合 prefs 的 atrributes
scala> prefs.attributes.map(attribute => p1.attributes.contains(attribute))
res1: Seq[Boolean] = List(false, true)

// 將吻合的特性標記為 分數 1.0
scala> res1 map (matched => if(matched) 1.0 else 0)
res2: Seq[Double] = List(0.0, 1.0)

// 計算總得分
scala> res2.sum / res2.length
res3: Double = 0.5

利用 specs 進行 unit test

首先在 build.sbt 增加一行

libraryDependencies += "org.specs2" % "specs2_2.10" % "1.14" % "test"

增加一個測試程式

import org.specs2.mutable.Specification

object LogicSpec extends Specification {
  "The 'matchLikelihood' method" should {
    "be 100% when all attributes match" in {
      val tabby = Product(1, List("male", "tabby"))
      val prefs = BuyerPreferences(List("male", "tabby"))
      Logic.matchLikelihood(tabby, prefs) must beGreaterThan(0.999)
    }
    "be 0% when no attributes match" in {
      val tabby = Product(1, List("male", "tabby"))
      val prefs = BuyerPreferences(List("female", "calico"))
      val result = Logic.matchLikelihood(tabby, prefs)
      result must beLessThan(0.001)
    }
    "correctly handle an empty BuyerPreferences" in {
      val tabby = Product(1, List("male", "tabby"))
      val prefs = BuyerPreferences(List())
      val result = Logic.matchLikelihood(tabby, prefs)
      result.isNaN mustEqual false
    }
  }
}

回到 sbt console(記得要跳出 scala interpreter),以 reload 重新載入 project 設定,以 test 進行 unit test

> reload
[info] Loading project definition from /project/idea/book/sbt-in-action-examples-master/chapter2/project
[info] Set current project to preowned-kittens (in build file:/project/idea/book/sbt-in-action-examples-master/chapter2/)
> test
[info] Compiling 1 Scala source to /project/idea/book/sbt-in-action-examples-master/chapter2/target/scala-2.10/test-classes...
[info] LogicSpec
[info]
[info] The 'matchLikelihood' method should
[info] + be 100% when all attributes match
[info] + be 0% when no attributes match
[info] + correctly handle an empty BuyerPreferences
[info]
[info]
[info] Total for specification LogicSpec
[info] Finished in 68 ms
[info] 3 examples, 0 failure, 0 error
[info]
[info] Passed: Total 3, Failed 0, Errors 0, Passed 3
[success] Total time: 4 s, completed 2016/5/12 上午 11:23:04

sbt 的測試還有另一個模式 ~test,可以持續等待 source code 的更新,並在更新後,自動執行 unit test,當 source code 有任何異動時,他會自動編譯並執行測試,在這個模式下的測試非常地有效率。

> ~test
[info] LogicSpec
[info]
[info] The 'matchLikelihood' method should
[info] + be 100% when all attributes match
[info] + be 0% when no attributes match
[info] + correctly handle an empty BuyerPreferences
[info]
[info]
[info] Total for specification LogicSpec
[info] Finished in 15 ms
[info] 3 examples, 0 failure, 0 error
[info]
[info] Passed: Total 3, Failed 0, Errors 0, Passed 3
[success] Total time: 1 s, completed 2016/5/12 上午 11:36:08
1. Waiting for source changes... (press enter to interrupt)

如果只要進行某一個單元測試,就要用 testOnly

> testOnly LogicSpec

如何定義 build.sbt

settings 裡面有三個 operators 用來建立 settings

  1. := 將新的 value 複寫原本的 key
  2. += 將新的 value 附加到原本 key 所儲存的 sequence 裡面
  3. ++= 將新的 sequence of values 附加到原本 key 所儲存的 sequence 裡面

定義 ModuleID 的格式為

"groupId" % "artifactId" % "version" 

例如 libraryDependencies 裡面就存放著 sequence of library dependencies,如果要定義兩個以上的 libraries,就要用以下的寫法

libraryDependencies ++= Seq(
    "junit" % "junit" % "4.11" % "test",
    "org.specs2" % "specs2_2.10" % "1.10" % "test"
)

自訂 key 的方式如下:gitHeadCommitSha 是 key 的名稱,型別為 String,而真實的值是由 scala.sys.process 裡面的 Process("git rev-parse HEAD") 運算得來的

val gitHeadCommitSha = taskKey[String]("Determines the current git commit SHA")

gitHeadCommitSha := Process("git rev-parse HEAD").lines.head

如果在一個 git project 裡面直接執行此指令,可以取得 hash value,所以上面的 gitHeadCommitSha 其實就是取得這個 hash value

> git rev-parse HEAD
8a0c542b032c78262f9e3a9a60cef318290c7d99

parallel execution

當 taskA depends on taskB, taskC,sbt 會嘗試同時執行 taskB與taskC,以下是驗證的方式:taskB, taskC 都會暫停 5 秒鐘,但 taskA 執行的時候,也是暫停 5s,這表示 taskB, taskC 確實是平行執行的

val taskA = taskKey[String]("taskA")
val taskB = taskKey[String]("taskB")
val taskC = taskKey[String]("taskC")
taskA := { val b = taskB.value; val c = taskC.value; "taskA" }
taskB := { Thread.sleep(5000); "taskB" }
taskC := { Thread.sleep(5000); "taskC" }

執行結果

> taskA
[success] Total time: 5 s, completed 2016/5/12 下午 03:19:12
> taskB
[success] Total time: 5 s, completed 2016/5/12 下午 03:19:30

subproject

sbt 支援 subproject,可以在子資料夾中,定義另一個 subproject,並在編譯時決定 project dependency。

在 build.sbt 中定義 common project,並以子資料夾 common 為 subproject 目錄

lazy val common = (
    Project("common", file("common")).
    settings()
)

在 sbt console可以用 projects 指令查閱

> projects
[info] In file:/project/idea/book/sbt-in-action-examples-master/chapter3/
[info]   * chapter3
[info]     temp
[info]     website

如果先定義一個共用的 project function: PreownedKittenProject,就可以在後面其他的 project 定義中,直接呼叫該 project function。

// Common settings/definitions for the build

def PreownedKittenProject(name: String): Project = (
  Project(name, file(name))
  settings(
    libraryDependencies += "org.specs2" % "specs2_2.10" % "1.14" % "test"
  )
)


lazy val common = (
  PreownedKittenProject("common")
  settings()
)

lazy val analytics = (
  PreownedKittenProject("analytics")
  dependsOn(common)
  settings()
)

lazy val website = (
  PreownedKittenProject("website")
  dependsOn(common)
  settings()
)

compile, run, test, package, publish

利用 inspect tree compile:compile 指令,可查詢編譯時,所需要的資源 tree,而 inspect tree sources 可查閱原始程式的 tree。

> inspect tree compile:compile
[info] chapter4/compile:compile = Task[sbt.inc.Analysis]
[info]   +-chapter4/compile:compile::compileInputs = Task[sbt.Compiler$Inputs]
[info]   | +-chapter4/compile:classDirectory = target/scala-2.10/classes
[info]   | +-*/*:compileOrder = Mixed
[info]   | +-chapter4/*:compilers = Task[sbt.Compiler$Compilers]
[info]   | +-chapter4/compile:dependencyClasspath = Task[scala.collection.Seq[sbt.Attributed[jav..
[info]   | +-chapter4/compile:incCompileSetup = Task[sbt.Compiler$IncSetup]
[info]   | +-*/*:javacOptions = Task[scala.collection.Seq[java.lang.String]]
[info]   | +-*/*:maxErrors = 100
[info]   | +-chapter4/compile:scalacOptions = Task[scala.collection.Seq[java.lang.String]]
[info]   | +-*/*:sourcePositionMappers = Task[scala.collection.Seq[scala.Function1[xsbti.Positio..
[info]   | +-chapter4/compile:sources = Task[scala.collection.Seq[java.io.File]]
[info]   | +-chapter4/compile:compile::streams = Task[sbt.std.TaskStreams[sbt.Init$ScopedKey[_ <..
[info]   |   +-*/*:streamsManager = Task[sbt.std.Streams[sbt.Init$ScopedKey[_ <: Any]]]
[info]   |
[info]   +-chapter4/compile:compile::compilerReporter = Task[scala.Option[xsbti.Reporter]]
[info]   +-chapter4/compile:compile::streams = Task[sbt.std.TaskStreams[sbt.Init$ScopedKey[_ <: ..
[info]     +-*/*:streamsManager = Task[sbt.std.Streams[sbt.Init$ScopedKey[_ <: Any]]]
[info]
> inspect tree sources
[info] chapter4/compile:sources = Task[scala.collection.Seq[java.io.File]]
[info]   +-chapter4/compile:managedSources = Task[scala.collection.Seq[java.io.File]]
[info]   | +-chapter4/compile:sourceGenerators = List()
[info]   |
[info]   +-chapter4/compile:unmanagedSources = Task[scala.collection.Seq[java.io.File]]
[info]     +-chapter4/*:baseDirectory = /Users/charley/project/idea/book/sbt-in-action-examples-..
[info]     +-*/*:sourcesInBase = true
[info]     +-chapter4/compile:unmanagedSourceDirectories = List(/Users/charley/project/idea/book..
[info]     | +-chapter4/compile:javaSource = src/main/java
[info]     | | +-chapter4/compile:sourceDirectory = src/main
[info]     | |   +-chapter4/*:sourceDirectory = src
[info]     | |   | +-chapter4/*:baseDirectory = /Users/charley/project/idea/book/sbt-in-action-e..
[info]     | |   |   +-chapter4/*:thisProject = Project(id chapter4, base: /Users/charley/projec..
[info]     | |   |
[info]     | |   +-chapter4/compile:configuration = compile
[info]     | |
[info]     | +-chapter4/compile:scalaSource = src/main/scala
[info]     |   +-chapter4/compile:sourceDirectory = src/main
[info]     |     +-chapter4/*:sourceDirectory = src
[info]     |     | +-chapter4/*:baseDirectory = /Users/charley/project/idea/book/sbt-in-action-e..
[info]     |     |   +-chapter4/*:thisProject = Project(id chapter4, base: /Users/charley/projec..
[info]     |     |
[info]     |     +-chapter4/compile:configuration = compile
[info]     |
[info]     +-*/*:excludeFilter = sbt.HiddenFileFilter$@731a5a39
[info]     +-*/*:unmanagedSources::includeFilter = sbt.SimpleFilter@1acd952e
[info]


> inspect tree test:sources
> 
> inspect tree compile:dependencyClasspath
  1. unmanagedSources 既有的 project convernsions 取得的 a list of source files
  2. managedSources 手動加入的 sources list
# 查閱 java source folders
> show javaSource

# 查閱 scala source folders
> show scalaSource

# 查閱資源目錄
> show resourceDirectory

sbt 預設會使用以下的 libray repositories

  • Bintray's JCenter
  • Maven Central
  • Typesafe releases
  • sbt community releases

Reference

sbt in action

2016年9月12日

Crosswalk in Android

曾經有一段時間,Facebook 為了達成跨平台的開發,是利用網頁的方式,製作 APP 的頁面,但最終還是敵不過效能的問題,改以原生的方式製作 APP。但從另一個角度來看,手機的瀏覽器在 HTML5 的支援進度不一,為了在不同瀏覽器都達成相同的畫面展示,工程師需要歷經很多時間去努力,相信大家都知道那一段 IE 的黑暗時代。

在 Android 雖然可以使用 WebView 進行頁面瀏覽,但其實在 Android SDK 升級之後,瀏覽器也跟著有些變化,因此雖然同樣使用 WebView,在不同版本的手機,也可能會發生一些問題。為了解決網頁程式在不同瀏覽器的展示差異,crosswalk 採用了另一種方式來解決,就是將原生的 Chromium 專案,整個包裝成一個手機開發的 Library,利用這個內建的瀏覽器,解決在不同 Android 版本的手機上,都可以達成相同的 UI 展示的問題。

使用 crosswalk 可以將整個瀏覽器封裝在 APP 中,這樣在使用 APP 時,雖然裡面是網頁,但實際上卻像是一個完整的 APP。但 crosswalk 也有缺點,就是在不同的 CPU Platform 都需要有對應的 binary library,以 Android 來說,要同時支援 x86 及 ARM CPU,必須增加數十 MB 的空間浪費。

就 crosswalk 的 FAQ 說明,目前已經有超過 300 個 APP 在 app store 中,大部分都是遊戲,或許是因為遊戲開發如果是以跨平台的角度來開發,網頁的遊戲可以利用 crosswalk 很快就包裝成 APP 出版,而使用者在面對遊戲這種應用,也比較能容忍 APP 本身消耗很多手機的儲存空間。The Power of Crosswalk 還提到了 crosswalk 能夠處理 WebCL、SIMD,這是舊版本 SDK 的 WebView 無法達到的功能。

測試怎麼使用 crosswalk

crosswalk android 文件 是以 command line 的方式進行包裝,我們測試時,是改用 Android Studio,由建立一個 Android Project 開始。

首先建立一個新的 Android 專案

Application name: CrosswalkTest
Company Domain: testme.com.tw

勾選 Phone and Tablet
Minimum SDK: API 21: Android 5.0

Empty Activity

Activity Name: MainActivity
勾選 Generate Layout File
Layout Name: activity_main

修改 AndroidManifest.xml,增加 APP 使用手機資源的權限

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="tw.com.testme.crosswalktest">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.NETWORK_ACCESS" />
    <uses-permission android:name="android.permission.BLUETOOTH"/>
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

其中最重要的是 android.permission.INTERNET、ndroid.permission.NETWORK_ACCESS 使用網路的權限,其他的部分都是附加的。

修改 app/build.gradle,增加 repositories 以及 dependencies 的內容

apply plugin: 'com.android.application'

android {
    compileSdkVersion 24
    buildToolsVersion "24.0.1"

    defaultConfig {
        applicationId "tw.com.testme.crosswalktest"
        minSdkVersion 21
        targetSdkVersion 24
        versionCode 1
        versionName "1.0"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

repositories {
    maven {
        url 'https://download.01.org/crosswalk/releases/crosswalk/android/maven2'
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    testCompile 'junit:junit:4.12'
    compile 'com.android.support:appcompat-v7:24.1.1'
    compile 'org.xwalk:xwalk_core_library:19.49.514.5'
}

修改 activity_main.xml,畫面的 Layout。crosswalk 是以 org.xwalk.core.XWalkView 取代原本 Android SDK 的 WebView。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="tw.com.testme.crosswalktest.MainActivity">
    
    <EditText
        android:id="@+id/editText1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:layout_alignParentTop="true"
        android:focusable="true"
        android:hint="Enter Text"
        android:text="http://www.maxkit.com.tw/"
        android:textColorHighlight="#ff7eff15"
        android:textColorHint="#ffff25e6" />

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true" />

    <Button
        android:id="@+id/button1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentEnd="true"
        android:layout_below="@+id/editText1"
        android:text="Enter" />

    <org.xwalk.core.XWalkView
        android:id="@+id/xwalkWebView"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:layout_alignParentEnd="true"
        android:layout_below="@+id/button1"
        android:orientation="vertical" />

</RelativeLayout>

修改 MainActivity.java

package tw.com.testme.crosswalktest;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;

import org.xwalk.core.XWalkPreferences;
import org.xwalk.core.XWalkSettings;
import org.xwalk.core.XWalkView;

public class MainActivity extends AppCompatActivity {

    private Button button1;
    private EditText editText1;

    private XWalkView xWalkWebView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        setContentView(R.layout.activity_main);

        button1 =(Button)findViewById(R.id.button1);
        editText1 =(EditText)findViewById(R.id.editText1);

        xWalkWebView=(XWalkView)findViewById(R.id.xwalkWebView);

        xWalkWebView.clearCache(true);

        XWalkSettings webSettings = xWalkWebView.getSettings();
//        webSettings.setJavaScriptEnabled(true);
//        webSettings.setCacheMode(WebSettings.LOAD_NO_CACHE);

        //xWalkWebView.load("javascript:document.body.contentEditable=true;", null);
        //xWalkWebView.load("https://www.google.com.tw", null);

        // turn on debugging
        XWalkPreferences.setValue(XWalkPreferences.REMOTE_DEBUGGING, true);
        XWalkPreferences.setValue(XWalkPreferences.JAVASCRIPT_CAN_OPEN_WINDOW, true);
        XWalkPreferences.setValue(XWalkPreferences.ALLOW_UNIVERSAL_ACCESS_FROM_FILE, true);

        xWalkWebView.setKeepScreenOn(true);
        xWalkWebView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN);
        String urlToBeLoaded = editText1.getText().toString();

        xWalkWebView.load(urlToBeLoaded, null);

        button1.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                String url = editText1.getText().toString();

                xWalkWebView.load(url, null);

            }
        });
    }

    @Override
    protected void onPause() {
        super.onPause();
        if (xWalkWebView != null) {
            xWalkWebView.pauseTimers();
            xWalkWebView.onHide();
        }
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (xWalkWebView != null) {
            xWalkWebView.resumeTimers();
            xWalkWebView.onShow();
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        if (xWalkWebView != null) {
            xWalkWebView.onDestroy();
        }
    }
}

執行 APP

在上面的 EditText 填寫網址,點 "Enter" 就可以在下面顯示網頁,但也不是每個網頁都可以顯示出來,這部分還需要去了解原因,目前判斷有可能是網頁的內容,crosswalk 沒辦法 render 出來的關係。

References

Crosswalk入門

Android Studio如何Import Module 即項目依賴(針對非Gradle項目,以Crosswalk為例)

Embedding Crosswalk in Android Studio

[Android] Crosswalk: 取代 WebView 的函式庫

為什麼放棄WebView