aboutsummaryrefslogtreecommitdiffstats
path: root/src/main/scala/com/iinteractive/test/TestMore.scala
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/scala/com/iinteractive/test/TestMore.scala')
-rw-r--r--src/main/scala/com/iinteractive/test/TestMore.scala519
1 files changed, 519 insertions, 0 deletions
diff --git a/src/main/scala/com/iinteractive/test/TestMore.scala b/src/main/scala/com/iinteractive/test/TestMore.scala
new file mode 100644
index 0000000..eb5210b
--- /dev/null
+++ b/src/main/scala/com/iinteractive/test/TestMore.scala
@@ -0,0 +1,519 @@
+package com.iinteractive.test
+
+import scala.util.matching.Regex
+
+import com.iinteractive.test.tap.TestBuilder
+
+/** This class is an implementation of the excellent
+ * [[https://metacpan.org/module/Test::More Test::More]] testing library for
+ * Perl. It provides a simple assertion-based testing API, which produces
+ * [[http://en.wikipedia.org/wiki/Test_Anything_Protocol TAP]], which can be
+ * parsed by any TAP consumer. This library includes several TAP-consuming
+ * harnesses to use with tests using this class, including one that supports
+ * testing via `sbt test`.
+ *
+ * ==Basics==
+ *
+ * The most basic test looks like this:
+ *
+ * <pre>
+ * class MyTest extends TestMore {
+ * ok(true)
+ * }
+ * </pre>
+ *
+ * This runs a test containing a single assertion. This will generate a TAP
+ * stream that looks like this:
+ *
+ * <pre>
+ * ok 1
+ * 1..1
+ * </pre>
+ *
+ * which can be parsed by one of the test harnesses provided by this library.
+ *
+ * ==Running tests==
+ *
+ * The simplest way to run tests is through sbt. You can register this
+ * framework with sbt by adding this line to your `build.sbt` file:
+ *
+ * <pre>
+ * testFrameworks += new TestFramework("com.iinteractive.test.sbt.Framework")
+ * </pre>
+ *
+ * Then, any classes in your test directory which extend `TestMore` will be
+ * automatically detected and run.
+ *
+ * ==Assertions==
+ *
+ * This class contains many more assertion methods than just `ok`. Here is a
+ * more extensive example (borrowed from Test::More's documentation):
+ *
+ * <pre>
+ * class MyTest extends TestMore {
+ * ok(got == expected, testName)
+ *
+ * is(got, expected, testName)
+ * isnt(got, expected, testName)
+ *
+ * diag("here's what went wrong")
+ *
+ * like(got, """expected""".r, testName)
+ * unlike(got, """expected""".r, testName)
+ *
+ * skip(howMany, why) {
+ * ok(foo(), testName)
+ * is(foo(42), 23, testName)
+ * }
+ *
+ * todo(why) {
+ * ok(foo(), testName)
+ * is(foo(42), 23, testName)
+ * }
+ *
+ * pass(testName)
+ * fail(testName)
+ *
+ * BAIL_OUT(why)
+ * }
+ * </pre>
+ *
+ * The difference between the simple `ok` method and the more specific
+ * methods like `is` and `like` is in how failures are reported. If you write
+ * this:
+ *
+ * <pre>
+ * ok(1 == 2)
+ * </pre>
+ *
+ * the output will look like this:
+ *
+ * <pre>
+ * not ok 1
+ * # Failed test at MyTest.scala line 4.
+ * </pre>
+ *
+ * On the other hand, a more specific assertion such as:
+ *
+ * <pre>
+ * is(1, 2)
+ * </pre>
+ *
+ * will produce more useful output:
+ *
+ * <pre>
+ * not ok 1
+ * # Failed test at MyTest.scala line 4.
+ * # got: 1
+ * # expected: 2
+ * </pre>
+ *
+ * In addition to assertions, there are also several methods which take a
+ * block of code to run, to modify the assertions contained in that block.
+ *
+ * The `todo` method runs tests which are expected to fail. If they do fail,
+ * the failure is reported to the test harness as a normal succeeding test,
+ * and nothing happens. If they succeed, they are still reported as a
+ * succeeding test, but a message is displayed to the user indicating that
+ * the todo indication can be removed.
+ *
+ * The `skip` method takes a block which should not be run at all. This is
+ * similar to `todo`, except that it is useful for tests which could cause
+ * problems if they were to actually run. Since the tests are never run, it's
+ * not possible to count how many tests there should be, so this must be
+ * specified as a parameter.
+ *
+ * The `subtest` method runs a block of assertions as though they were an
+ * entirely separate test, and then reports the result of that test as a
+ * single assertion in the test that called `subtest`.
+ *
+ * ==Test plans==
+ *
+ * Normally, you can run any number of assertions within your class body, and
+ * the framework will assume that if no exceptions were thrown, all of the
+ * assertions that were meant to be run were actually run. Sometimes,
+ * however, that may not be a safe assumption, especially with heavily
+ * callback-driven code. In this case, you can specify exactly how many tests
+ * you intend to run, and the number of tests actually run will be checked
+ * against this at the end. To declare this, give a number to the `TestMore`
+ * constructor:
+ *
+ * <pre>
+ * class MyTest extends TestMore(5) {
+ * ???
+ * }
+ * </pre>
+ *
+ * In addition, if the entire test should be skipped, you can give a plan of
+ * `SkipAll()`:
+ *
+ * <pre>
+ * class MyTest extends TestMore(SkipAll()) {
+ * ???
+ * }
+ * </pre>
+ *
+ * ==Extensions==
+ *
+ * These assertion methods are written with the intention of being
+ * composable. You can write your own test methods which call `is` or `ok` on
+ * more specific bits of data. The one issue here is that, as shown above,
+ * test failure messages refer to the file and line where the `is` or `ok`
+ * call was made. If you want this to instead point at the line where your
+ * assertion helper method was called, you can use the `hideTestMethod`
+ * method like this:
+ *
+ * <pre>
+ * trait MyTestHelpers { this: TestMore =>
+ * def notok (cond: Boolean) = hideTestMethod {
+ * ok(!cond)
+ * }
+ * }
+ * </pre>
+ *
+ * This way, the test failure will be reported from the line where `notok`
+ * was called, not from the call to `ok` in the `notok` method.
+ */
+class TestMore (plan: Plan = NoPlan) extends Test with DelayedInit {
+ def delayedInit (body: => Unit) {
+ testBody = { terminalInUse =>
+ todo = None
+ builder = new TestBuilder(plan, terminalInUse)
+ plan match {
+ case SkipAll(_) => ()
+ case _ => body
+ }
+ }
+ }
+
+ protected def runTests (terminalInUse: Boolean): Int = {
+ if (testBody == null) {
+ delayedInit { }
+ }
+
+ testBody(terminalInUse)
+ builder.doneTesting
+ builder.exitCode
+ }
+
+ /** Assert that a condition is true.
+ *
+ * @example `ok(response.isSuccess)`
+ */
+ def ok (cond: Boolean): Boolean =
+ test(cond)
+
+ /** Assert that a condition is true, and describe the assertion.
+ *
+ * @example `ok(response.isSuccess, "the response succeeded")`
+ */
+ def ok (cond: Boolean, desc: String): Boolean =
+ testWithDesc(cond, desc)
+
+ /** Assert that two objects are equal (using `==`).
+ *
+ * @example `is(response.status, 200)`
+ */
+ def is[T] (got: T, expected: T): Boolean =
+ test(got == expected, isMessage(got, expected))
+
+ /** Assert that two objects are equal (using `==`), and describe the
+ * assertion.
+ *
+ * @example `is(response.status, 200, "we got a 200 OK response")`
+ */
+ def is[T] (got: T, expected: T, desc: String): Boolean =
+ testWithDesc(got == expected, desc, isMessage(got, expected))
+
+ /** Assert that two objects are not equal (using `!=`).
+ *
+ * @example `isnt(response.body, "")`
+ */
+ def isnt[T] (got: T, expected: T): Boolean =
+ test(got != expected, isntMessage(got))
+
+ /** Assert that two objects are not equal (using `!=`), and describe the
+ * assertion.
+ *
+ * @example `isnt(response.body, "", "we got a response body")`
+ */
+ def isnt[T] (got: T, expected: T, desc: String): Boolean =
+ testWithDesc(got != expected, desc, isntMessage(got))
+
+ /** Assert that a string matches a regular expression.
+ *
+ * @example `like(response.header("Content-Type"), """text/x?html""".r)`
+ */
+ def like (got: String, rx: Regex): Boolean =
+ test(rx.findFirstIn(got).nonEmpty, likeMessage(got, rx))
+
+ /** Assert that a string matches a regular expression, and describe the
+ * assertion.
+ *
+ * @example `like(response.header("Content-Type"), """text/x?html""".r, "we got an html content type")`
+ */
+ def like (got: String, rx: Regex, desc: String): Boolean =
+ testWithDesc(rx.findFirstIn(got).nonEmpty, desc, likeMessage(got, rx))
+
+ /** Assert that a string doesn't match a regular expression.
+ *
+ * @example `unlike(response.header("Authorization"), """^Digest.*""".r)`
+ */
+ def unlike (got: String, rx: Regex): Boolean =
+ test(rx.findFirstIn(got).isEmpty, unlikeMessage(got, rx))
+
+ /** Assert that a string doesn't match a regular expression.
+ *
+ * @example `unlike(response.header("Authorization"), """^Digest.*""".r, "we don't support digest authentication")`
+ */
+ def unlike (got: String, rx: Regex, desc: String): Boolean =
+ testWithDesc(rx.findFirstIn(got).isEmpty, desc, unlikeMessage(got, rx))
+
+ /** An assertion that always succeeds.
+ *
+ * @example `pass()`
+ */
+ def pass: Boolean =
+ ok(true)
+
+ /** An assertion that always succeeds, with a reason.
+ *
+ * @example `pass("this line of code should be executed")`
+ */
+ def pass (desc: String): Boolean =
+ ok(true, desc)
+
+ /** An assertion that always fails.
+ *
+ * @example `fail()`
+ */
+ def fail: Boolean =
+ ok(false)
+
+ /** An assertion that always fails, with a reason.
+ *
+ * @example `fail("we should never get here")`
+ */
+ def fail (desc: String): Boolean =
+ ok(false, desc)
+
+ /** Output a comment to `Console.err`. This is intended to be visible to
+ * users even when running the test under a summarizing harness.
+ *
+ * @example `diag("Testing with Scala " + util.Properties.versionString)`
+ */
+ def diag (message: String) {
+ builder.diag(message)
+ }
+
+ /** Output a comment to `Console.out`. This is intended to only be visible
+ * when viewing the raw TAP stream.
+ *
+ * @example `note("Starting the response tests")`
+ */
+ def note (message: String) {
+ builder.note(message)
+ }
+
+ /** Halt execution of the entire test suite.
+ *
+ * @example `BAIL_OUT("can't connect to the database!")`
+ */
+ def BAIL_OUT (desc: String) {
+ builder.bailOut(desc)
+ }
+
+ /** Mark a block of tests as expected to fail. If the tests which run in the
+ * todo block fail, they will not be treated as test failures, and if they
+ * succeed, the user will be notified.
+ *
+ * @example `todo("waiting on fixes elsewhere") { ??? }`
+ */
+ def todo (reason: String)(body: => Unit) {
+ val oldTodo = todo
+ try {
+ todo = Some(reason)
+ body
+ }
+ finally {
+ todo = oldTodo
+ }
+ }
+
+ /** Mark a block of tests that should not be run at all. They are treated as
+ * always passing.
+ *
+ * @example `skip(3, "too dangerous to run for now") { ??? }`
+ */
+ def skip (count: Int, reason: String)(body: => Unit) {
+ for (i <- 1 to count) {
+ builder.skip(reason)
+ }
+ }
+
+ /** Declare a logical group of assertions, to be run as a single test. This
+ * is effectively an entirely separate test, which is run, and the result
+ * of that test is reported as a single assertion in the test that contains
+ * it. The subtest can specify its own plan in the same way that the
+ * overall test is allowed to. The name will be used as the description for
+ * the single assertion that the overall test sees.
+ *
+ * @example `subtest("response tests") { ??? }`
+ */
+ def subtest (
+ name: String,
+ plan: Plan = NoPlan
+ )(body: => Unit): Boolean = {
+ val oldBuilder = builder
+ val success = try {
+ builder = oldBuilder.cloneForSubtest(plan)
+ body
+ builder.doneTesting
+ }
+ finally {
+ builder = oldBuilder
+ }
+ ok(success, name)
+ }
+
+ /** A helper method which should be used to wrap test utility methods.
+ * Normally, when tests fail, a message is printed giving the file and line
+ * number of the call to the test method. If you write your own test
+ * methods, they will typically use the existing methods to generate
+ * assertions, and so the file and line numbers will likely be much less
+ * useful. Wrapping the body of your method in this method will ensure that
+ * the file and line number that is reported is the line where your helper
+ * method is called instead.
+ *
+ * @example `def testFixtures = hideTestMethod { ??? }`
+ */
+ def hideTestMethod[T] (body: => T): T = {
+ // this just adds a method call with a known name to the stack trace, so
+ // that we can detect it later
+ body
+ }
+
+ private def isMessage[T] (got: T, expected: T): String =
+ " got: '" + got + "'\n" +
+ " expected: '" + expected + "'\n"
+
+ private def isntMessage[T] (got: T): String =
+ " got: '" + got + "'\n" +
+ " expected: anything else\n"
+
+ private def likeMessage (got: String, rx: Regex): String =
+ " '" + got + "'\n" +
+ " doesn't match '" + rx + "'\n"
+
+ private def unlikeMessage (got: String, rx: Regex): String =
+ " '" + got + "'\n" +
+ " matches '" + rx + "'\n"
+
+ private def testWithDesc (
+ cond: Boolean,
+ desc: String
+ ): Boolean = {
+ todo match {
+ case Some(t) => builder.todo(t, cond, "- " + desc)
+ case None => builder.ok(cond, "- " + desc)
+ }
+ if (!cond) {
+ failed(Some(desc), None)
+ }
+ cond
+ }
+
+ private def testWithDesc (
+ cond: Boolean,
+ desc: String,
+ reason: => String
+ ): Boolean = {
+ todo match {
+ case Some(t) => builder.todo(t, cond, "- " + desc)
+ case None => builder.ok(cond, "- " + desc)
+ }
+ if (!cond) {
+ failed(Some(desc), Some(reason))
+ }
+ cond
+ }
+
+ private def test (cond: Boolean): Boolean = {
+ todo match {
+ case Some(t) => builder.todo(t, cond)
+ case None => builder.ok(cond)
+ }
+ if (!cond) {
+ failed(None, None)
+ }
+ cond
+ }
+
+ private def test (cond: Boolean, reason: => String): Boolean = {
+ todo match {
+ case Some(t) => builder.todo(t, cond)
+ case None => builder.ok(cond)
+ }
+ if (!cond) {
+ failed(None, Some(reason))
+ }
+ cond
+ }
+
+ private def failed (desc: Option[String], reason: Option[String]) {
+ val stack = Thread.currentThread.getStackTrace.drop(1).filter { frame =>
+ !ignoreFrame(frame)
+ }
+ val idx = stack.lastIndexWhere { frame =>
+ frame.getClassName == "com.iinteractive.test.TestMore" &&
+ frame.getMethodName == "hideTestMethod"
+ }
+ val caller = idx match {
+ case -1 => stack.headOption
+ // one level to jump out of hideTestMethod and one level to jump out of
+ // the method that called hideTestMethod
+ case i => stack.drop(i + 2).headOption
+
+ }
+ val (file, line) = caller match {
+ case Some(frame) => (frame.getFileName, frame.getLineNumber)
+ case None => ("<unknown file>", "<unknown line>")
+ }
+ val message = " " + (todo match {
+ case Some(_) => "Failed (TODO) test"
+ case None => "Failed test"
+ }) + (desc match {
+ case Some(m) => " '" + m + "'\n "
+ case None => " "
+ })
+ val trace = "at " + file + " line " + line + "."
+ val explanation = message + trace + reason.map("\n" + _).getOrElse("")
+ if (todo.isDefined) {
+ builder.note(explanation)
+ }
+ else {
+ builder.diag(explanation)
+ }
+ }
+
+ protected def ignoreFrame (frame: StackTraceElement): Boolean = {
+ val className = frame.getClassName
+ val methodName = frame.getMethodName
+
+ // ignore everything in this class, except the hideTestMethod call which we
+ // use as a stack trace marker
+ (className == "com.iinteractive.test.TestMore" &&
+ methodName != "hideTestMethod") ||
+ // when you call a method in a class when the method is defined in a
+ // trait, it calls a stub which calls the real definition in the trait.
+ // the trait is represented under the hood as a class with the same name
+ // as the trait, except with $class appended. this is a gross reliance on
+ // implementation details that could change at any moment, but i don't
+ // really see any better options.
+ """\$class$""".r.findFirstIn(className).nonEmpty
+ }
+
+ private var todo: Option[String] = _
+ private var builder: TestBuilder = _
+ private var testBody: Boolean => Unit = _
+}