Skip to content

Commit 0bb7706

Browse files
committed
Add more tests, introduce TestFetching.withLookup
1 parent b1ac249 commit 0bb7706

File tree

5 files changed

+136
-42
lines changed

5 files changed

+136
-42
lines changed

core/src/test/scala/com/gu/etagcaching/FreshnessPolicyTest.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.gu.etagcaching
33
import com.github.blemale.scaffeine.{AsyncLoadingCache, Scaffeine}
44
import com.gu.etagcaching.FreshnessPolicy.{AlwaysWaitForRefreshedValue, TolerateOldValueWhileRefreshing}
55
import com.gu.etagcaching.fetching.{ETaggedData, Fetching, MissingOrETagged}
6+
import com.gu.etagcaching.testkit.TestFetching
67
import org.scalatest.OptionValues
78
import org.scalatest.concurrent.ScalaFutures
89
import org.scalatest.concurrent.TimeLimits.failAfter
Lines changed: 74 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,97 @@
11
package com.gu.etagcaching
22

3-
import com.gu.etagcaching.FreshnessPolicy.TolerateOldValueWhileRefreshing
3+
import com.gu.etagcaching.FreshnessPolicy.AlwaysWaitForRefreshedValue
44
import com.gu.etagcaching.Loading.Update
5-
import com.gu.etagcaching.fetching.Fetching
6-
import org.scalatest.OptionValues
5+
import com.gu.etagcaching.LoadingTest.TestApparatus
6+
import com.gu.etagcaching.fetching.{ETaggedData, Fetching}
7+
import com.gu.etagcaching.testkit.{CountingParser, TestFetching}
8+
import org.scalatest.OptionValues.convertOptionToValuable
9+
import org.scalatest.concurrent.ScalaFutures.convertScalaFuture
710
import org.scalatest.concurrent.{Eventually, ScalaFutures}
811
import org.scalatest.flatspec.AnyFlatSpec
912
import org.scalatest.matchers.should.Matchers
13+
import org.scalatest.matchers.should.Matchers.convertToAnyShouldWrapper
14+
import org.scalatest.{Inside, OptionValues}
1015

16+
import java.time.DayOfWeek
17+
import java.time.DayOfWeek.{MONDAY, SATURDAY, THURSDAY}
18+
import java.util.Locale
19+
import java.util.Locale.{FRANCE, GERMANY, UK}
1120
import scala.collection.mutable
1221
import scala.concurrent.ExecutionContext.Implicits.global
13-
import scala.concurrent.duration._
1422

15-
class LoadingTest extends AnyFlatSpec with Matchers with ScalaFutures with OptionValues with Eventually {
16-
"onUpdate" should "give callbacks that allow logging updates" in {
17-
val updates: mutable.Buffer[Update[String, Int]] = mutable.Buffer.empty
23+
object LoadingTest {
24+
/**
25+
* Uses a mock (optionally mutable) Map 'dataStore'. Provides an instance of [[Loading]] that fetches from that
26+
* datastore, and parses using the provided parser (counting how many times that parsing occurs).
27+
*/
28+
class TestApparatus[K, Response, V](dataStore: scala.collection.Map[K, Response])(parser: Response => V) {
29+
private val countingParser = new CountingParser[Response, V](parser)
30+
val loading: Loading[K, V] = TestFetching.withStubDataStore(dataStore).thenParsing(countingParser)
1831

19-
val fetching: Fetching[String, Int] = TestFetching.withIncrementingValues
32+
def parseCount(): Long = countingParser.count()
33+
34+
def parsesCountedDuringConditionalLoadOf(k: K, oldV: ETaggedData[V]): Long = {
35+
val before = parseCount()
36+
loading.fetchThenParseIfNecessary(k, oldV).futureValue.toOption.value shouldBe parser(dataStore(k))
37+
parseCount() - before
38+
}
39+
}
40+
}
41+
42+
class LoadingTest extends AnyFlatSpec with Matchers with ScalaFutures with OptionValues with Eventually with Inside {
43+
44+
"Creating a Loading instance from a Fetching instance" should "be done with 'thenParsing'" in {
45+
val fetching: Fetching[Locale, String] =
46+
TestFetching.withStubDataStore(Map(FRANCE -> "THURSDAY", GERMANY -> "MONDAY"))
47+
48+
val loading: Loading[Locale, DayOfWeek] = fetching.thenParsing(DayOfWeek.valueOf)
49+
50+
loading.fetchAndParse(FRANCE).futureValue.toOption.value shouldBe THURSDAY
51+
loading.fetchAndParse(GERMANY).futureValue.toOption.value shouldBe MONDAY
52+
}
53+
54+
"fetchThenParseIfNecessary" should "*only* do parsing if fetching found a change in ETag value" in {
55+
val dataStore = mutable.Map(UK -> "SATURDAY")
56+
val testApparatus = new TestApparatus(dataStore)(parser = DayOfWeek.valueOf)
57+
58+
inside(testApparatus.loading.fetchAndParse(UK).futureValue) { case initialLoad: ETaggedData[DayOfWeek] =>
59+
testApparatus.parseCount() shouldBe 1
60+
initialLoad.result shouldBe SATURDAY
61+
62+
// No additional parse performed, as UK value's ETag unchanged
63+
testApparatus.parsesCountedDuringConditionalLoadOf(UK, initialLoad) shouldBe 0
64+
65+
dataStore(UK) = "MONDAY"
66+
67+
// UK's ETag changed, we must parse the new value!
68+
testApparatus.parsesCountedDuringConditionalLoadOf(UK, initialLoad) shouldBe 1
69+
}
70+
}
71+
72+
"onUpdate" should "provide callbacks that allow logging updates" in {
73+
val dataStore = mutable.Map(UK -> "SATURDAY")
74+
val testApparatus = new TestApparatus(dataStore)(parser = DayOfWeek.valueOf)
75+
76+
val updates: mutable.Buffer[Update[Locale, DayOfWeek]] = mutable.Buffer.empty
2077

2178
val cache = new ETagCache(
22-
fetching.thenParsing(identity).onUpdate { update =>
23-
updates.append(update)
24-
},
25-
TolerateOldValueWhileRefreshing,
26-
_.maximumSize(1).refreshAfterWrite(100.millis)
79+
testApparatus.loading.onUpdate(update => updates.append(update)),
80+
AlwaysWaitForRefreshedValue,
81+
_.maximumSize(1)
2782
)
2883

2984
val expectedUpdates = Seq(
30-
Update("key", None, Some(0)),
31-
Update("key", Some(0), Some(1))
85+
Update(UK, None, Some(SATURDAY)),
86+
Update(UK, Some(SATURDAY), Some(MONDAY))
3287
)
3388

34-
cache.get("key").futureValue shouldBe Some(0)
89+
cache.get(UK).futureValue shouldBe Some(SATURDAY)
3590
updates shouldBe expectedUpdates.take(1)
3691

37-
Thread.sleep(105)
38-
39-
eventually { cache.get("key").futureValue shouldBe Some(1) }
40-
updates.toSeq shouldBe expectedUpdates
92+
dataStore(UK) = "MONDAY"
4193

42-
Thread.sleep(105)
43-
updates.toSeq shouldBe expectedUpdates // No updates if we're not requesting the key from the cache
94+
cache.get(UK).futureValue shouldBe Some(MONDAY)
95+
updates shouldBe expectedUpdates
4496
}
4597
}

core/src/test/scala/com/gu/etagcaching/TestFetching.scala

Lines changed: 0 additions & 20 deletions
This file was deleted.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.gu.etagcaching.testkit
2+
3+
import java.util.concurrent.atomic.AtomicLong
4+
5+
class CountingParser[K,V](parser: K => V) extends (K => V) {
6+
7+
private val counter = new AtomicLong()
8+
9+
override def apply(key: K): V = {
10+
counter.incrementAndGet()
11+
parser(key)
12+
}
13+
14+
def count(): Long = counter.get()
15+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.gu.etagcaching.testkit
2+
3+
import com.gu.etagcaching.fetching.{ETaggedData, Fetching, Missing, MissingOrETagged}
4+
5+
import java.util.concurrent.atomic.AtomicInteger
6+
import scala.concurrent.{ExecutionContext, Future}
7+
8+
/**
9+
* Creates various test-instances of [[Fetching]] for test fixtures.
10+
*/
11+
object TestFetching {
12+
13+
def withIncrementingValues: Fetching[String, Int] = new Fetching[String, Int] {
14+
val counter = new AtomicInteger()
15+
16+
override def fetch(key: String): Future[MissingOrETagged[Int]] = {
17+
val count = counter.getAndIncrement()
18+
Future.successful(ETaggedData(count.toString, count))
19+
}
20+
override def fetchOnlyIfETagChanged(key: String, eTag: String): Future[Option[MissingOrETagged[Int]]] =
21+
fetch(key).map(Some(_))(ExecutionContext.parasitic)
22+
}
23+
24+
/**
25+
* Create a test [[Fetching]] instance with a (possibly impure) `lookup` function.
26+
*
27+
* The [[Fetching]] instance will use the object's hashcode to create the object's ETag.
28+
*
29+
* The `lookup` function can return different values for the same input if we want to simulate
30+
* a key's value changing over time.
31+
*/
32+
def withLookup[K, V](lookup: K => Option[V]): Fetching[K, V] = new Fetching[K, V] {
33+
override def fetch(key: K): Future[MissingOrETagged[V]] = Future.successful {
34+
lookup(key).fold[MissingOrETagged[V]](Missing) { value =>
35+
ETaggedData(value.hashCode().toString, value)
36+
}
37+
}
38+
override def fetchOnlyIfETagChanged(key: K, ETag: String): Future[Option[MissingOrETagged[V]]] =
39+
fetch(key).map {
40+
case ETaggedData(ETag, _) => None
41+
case other => Some(other)
42+
}(ExecutionContext.parasitic)
43+
}
44+
45+
def withStubDataStore[K, V](dataStore: scala.collection.Map[K, V]): Fetching[K, V] = withLookup(dataStore.get)
46+
}

0 commit comments

Comments
 (0)