Opacity
Step 1 - difficult to maintain but working code
The main idea of our sample program is to raise and lower widget opacity in cycles. In first attempt "time running" logic is embedded into widget making difficult to write consistent predictable test.
enum AnimationOrder { UP, DOWN }
void go() {
Timer.periodic(Duration(milliseconds: 50), _progressAnimation());
}
//this is part of a widget so "time iterating" logic is highly coupled with widget/ui layer.
//This combination is very difficult for testing because it depends on time - which we can not
//control during tests
_progressAnimation() => (Timer _) {
switch (_animationOrder) {
case AnimationOrder.UP:
_progress++;
if (_progress >= 100) _animationOrder = AnimationOrder.DOWN;
break;
case AnimationOrder.DOWN:
_progress--;
if (_progress <= 0) _animationOrder = AnimationOrder.UP;
break;
}
print("_progress : $_progress");
setState(() {});
};
final _animation = Center(
child: Opacity(
opacity: (_progress.toDouble() / 100.0),
child: Text("some text"),
),
);
Step 2 - extract class responsible for cyclic time measurement
Here at least we extracted logic from the widget but the problem of testing is not solved. Widget has direct dependency on CyclicTimer and CyclicTimer has dependency on non-deterministic timer.
enum _CycleOrder { UP, DOWN }
class CyclicTimer extends ChangeNotifier{
final int _limit;
var _currentValue=1;
var _cycleOrder = _CycleOrder.UP;
CyclicTimer([this._limit=1]);
start(){
Timer.periodic(Duration(milliseconds: 50), _runCycle);
}
value() => _currentValue;
_runCycle(Timer _){
switch (_cycleOrder) {
case _CycleOrder.UP:
_currentValue++;
if (_currentValue >= _limit) _cycleOrder = _CycleOrder.DOWN;
break;
case _CycleOrder.DOWN:
_currentValue--;
if (_currentValue <= 0) _cycleOrder = _CycleOrder.UP;
break;
}
notifyListeners();
}
}
Step 3 - make widget test deterministic
We need to decouple widget from system time based timer implementation
abstract class CyclicTimer extends ChangeNotifier{
void start();
int value();
}
class PeriodicCyclicTimer extends CyclicTimer{
...
}
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState(PeriodicCyclicTimer());
}
Now we can inject our own implementation in tests
class TimerForTests extends CyclicTimer {
var v = 0;
@override
void start() {}
@override
int value() => v;
void changeCounter(int newValue) {
v = newValue;
notifyListeners();
}
}
And to have full control over a widget creation we will us it in our custom structure
class _TestApp extends StatelessWidget {
final TimerForTests timer;
const _TestApp(this.timer);
@override
Widget build(BuildContext context) => MaterialApp(
home: HomePage(timer),
);
}
And Finally our tests where we are testing if our widgets reacts to timer changes
Opacity _opacityWidget(){
var opacityFinder = find.byType(Opacity);
return opacityFinder.evaluate().first.widget as Opacity;
}
testWidgets('timer based opacity change', (WidgetTester tester) async {
final timer = TimerForTests(); //our controlled deterministic timer
await tester.pumpWidget(_TestApp(timer));
expect(_opacityWidget().opacity, 0.1);
//start animation
await tester.tap(find.byKey(Key("AnimationButton")));
await tester.pump();
//lets check if opacity is rising correctly
timer.changeCounter(50);
await tester.pump();
expect(_opacityWidget().opacity, 0.5);
//and now if it is correctly substracted
timer.changeCounter(20);
await tester.pump();
expect(_opacityWidget().opacity, 0.2);
});
Step 4 - make widget test deterministic
To achieve this we need to "abstract away" usage of system timer inside our class. We did it by creating our own representation of time scheduler which can be easily mocked in tests - SimpleRunner
mixin SimpleRunner{
void run(VoidCallback logic);
}
class TimerSimpleRunner with SimpleRunner{
@override
void run(VoidCallback logic) {
Timer.periodic(Duration(milliseconds: 50),(Timer _ ) => logic());
}
}
So now we can use interface directly in our timer class.
class PeriodicCyclicTimer extends CyclicTimer{
final int _limit;
final SimpleRunner _runner;
var _currentValue=1;
var _cycleOrder = _CycleOrder.UP;
PeriodicCyclicTimer(this._limit, this._runner);
@override start(){
_runner.run(runCycle);
}
@override value() => _currentValue;
runCycle(){
switch (_cycleOrder) {
case _CycleOrder.UP:
_currentValue++;
if (_currentValue >= _limit) _cycleOrder = _CycleOrder.DOWN;
break;
case _CycleOrder.DOWN:
_currentValue--;
if (_currentValue <= 0) _cycleOrder = _CycleOrder.UP;
break;
}
notifyListeners();
}
}