Introduction: Test-driven Development (TDD) is obscure in the understanding of many domestic software developers at present, and most of them have not explicitly and consciously implemented TDD, so many people have different understanding, including myself before the practice of TDD. However, there is a good saying that “Practice is the only criterion for testing truth, and any conclusion made without practice is a fool” (the latter part of the sentence is my own, yes). This article contains some thoughts about my PRACTICE of TDD in Flutter, based on my real experience and for reference only

Prerequisites: Some knowledge of Flutter, Dart, Flutter test, and TDD

Doubt and resistance

  • feelTDDThe value that it brings,TDDBroke the conventional development ideas
  • thinkTDDTedious, clearly can be realized in one breath of code, why do not have to dismantle details
  • Write use cases first, but don’t know how to design use cases
  • Think your use case is silly and doesn’t feel useful
  • I wrote the code logic is very simple, there will be no problem, no need to write a single test
  • It says, “You found the previous use case doesn’t seem right, want to use it instead?
  • How to disassemble use cases? How to control granularity?
  • When do you refactor?

1. Create something from nothing

Example: Implement a general Flutter list that supports slip-up loading and pull-down refreshing

Use case combing:

  • Loading animation is displayed during loading
  • The empty page is displayed as the result of loading an empty list
  • The error page is displayed when the loading result fails
  • .

At the beginning, only three use cases were sorted out. In order to focus, all scenarios were not considered. Theoretically, TDD can gradually supplement use cases and improve functions

Try the TDD process: Write a single test case first -> use case fails -> write the smallest runnable single test version implementation

1.1 First use case: Loading process shows loading animation

Write a single measurement

Consider: currently there is no implementation code, which means that how to write a single test is completely different from the concrete implementation. It must be as simple as possible (no need for mock), and it is not even reasonable to describe the use case requirements with single test code

**Given: ** First, I definitely need to prepare a Widget, because the three use cases are different loading Status corresponding to different display widgets, so I will design this Widget to need a Status parameter, regardless of rationality and scalability, at least for now measurable (will involve refactoring later).

When: Loads the Widget and passes the parameter loading to indicate that it is loading

**Then: ** Verifies whether loading widgets appear on the current page

Coding implementation:

void main() {
  testWidgets("List loading state display loading", (tester) async {
    FeedList feedList = const FeedList(loadingStatus: LoadingStatus.loading);
    await tester.pumpWidget(MaterialApp(home: feedList));
    var loadingFinder = find.bySemanticsLabel("feed_loading");
    expect(loadingFinder, findsOneWidget, reason: Loading control not found);
  });
}
Copy the code

The use case failed to run

This use case is certainly unbeatable at the moment

First, there is no FeedList widget

Second, there can’t be a semantics widget called feed_loading

Write the smallest runnable single-test version of the implementation

enum LoadingStatus {
  loading,
}

class FeedList extends StatelessWidget {
  final LoadingStatus loadingStatus;

  const FeedList({
    Key? key,
    required this.loadingStatus,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // Note that there is no judgment state, but a loading state
    // Since there is only one use case so far, this code is enough to get the use case through
    return Semantics(
      label: "feed_loading", child: Container(), ); }}Copy the code

This way, the previous use case can run through

Think about it: You can see that the current implementation sucks, is not the version of the implementation that we expected for the feature, but just gets the use case through. In our normal development process or habits, we might be tempted to optimize the code, think about boundary conditions, and write a better version of the implementation. Here, for example, we might customarily define enumerations of various states, and then inbuildWhen judging various states, to achieve the processing logic of each state. This seems to be very convenient things, we do not do now, according toTDDAt this point, we must not optimize the code too early to write implementations other than use cases. One rule to keep in mind is that every line of code we write should be as good as possibleFirst,Write test cases to cover, i.eWrite the test case first, then the implementation

Let’s hold off on optimizing or refactoring for now, but let’s keep going

1.2 Second use case: Load an empty list and display an empty page

Write a single measurement

With the previous code, the second use case will naturally be entered in a different state, which also shows that our previous design is relatively testable so far. The code is as follows

  testWidgets("Empty list status shows empty list widget after loading", (tester) async {
      FeedList feedList = const FeedList(loadingStatus: LoadingStatus.empty);
      await tester.pumpWidget(MaterialApp(home: feedList));
      var loadingFinder = find.bySemanticsLabel(FeedList.semanticsFeedEmpty);
      expect(loadingFinder, findsOneWidget, reason: "No empty list controls found");
    });
Copy the code

The use case failed to run

After adding this use case, now run a single test: the first use case succeeds, the second fails

Obviously, we only implemented the loading state and didn’t even judge the input, so the second use case must have failed

Write the smallest runnable single-test version of the implementation

In order for both use cases to pass, we now have to load the judgment logic

enum LoadingStatus {
  loading,
  empty,
}

class FeedList extends StatelessWidget {
  static const semanticsFeedLoading = "feed_loading";
  static const semanticsFeedEmpty = "feed_empty";

  final LoadingStatus loadingStatus;

  const FeedList({
    Key? key,
    required this.loadingStatus,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // Add judgment logic
    switch (loadingStatus) {
      case LoadingStatus.loading:
        return Semantics(
          label: semanticsFeedLoading,
          child: Container(),
        );
      case LoadingStatus.empty:
        return Semantics(
          label: semanticsFeedEmpty,
          child: Container(),
        );
      default:
        return constSizedBox(); }}}Copy the code

In this way, both use cases will pass

1.3 The third case: The error page is displayed when the loading result fails

With the first two use cases and implementation matting, there is nothing left to say about the third use case, just add a judgment logic, and the final single test code and implementation is as follows

void main() {
  group("Feed loading status displays different widgets", () {
    testWidgets("List loading state display loading", (tester) async {
      FeedList feedList = const FeedList(loadingStatus: LoadingStatus.loading);
      await tester.pumpWidget(MaterialApp(home: feedList));
      var loadingFinder = find.bySemanticsLabel(FeedList.semanticsFeedLoading);
      expect(loadingFinder, findsOneWidget, reason: Loading control not found);
    });

    testWidgets("Empty list status shows empty list widget after loading", (tester) async {
      FeedList feedList = const FeedList(loadingStatus: LoadingStatus.empty);
      await tester.pumpWidget(MaterialApp(home: feedList));
      var loadingFinder = find.bySemanticsLabel(FeedList.semanticsFeedEmpty);
      expect(loadingFinder, findsOneWidget, reason: "No empty list controls found");
    });

    testWidgets("Failure widget showing failure status after loading", (tester) async {
      FeedList feedList = const FeedList(loadingStatus: LoadingStatus.failed);
      await tester.pumpWidget(MaterialApp(home: feedList));
      var loadingFinder =
          find.bySemanticsLabel(FeedList.semanticsFeedLoadFailed);
      expect(loadingFinder, findsOneWidget, reason: "Failed to load control found");
    });
  });
}
Copy the code
import 'package:flutter/widgets.dart';

enum LoadingStatus {
  loading,
  empty,
  failed,
  loaded,
}

class FeedList extends StatelessWidget {
  static const semanticsFeedLoading = "feed_loading";
  static const semanticsFeedEmpty = "feed_empty";
  static const semanticsFeedLoadFailed = "feed_load_failed";

  final LoadingStatus loadingStatus;

  const FeedList({
    Key? key,
    required this.loadingStatus,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    switch (loadingStatus) {
      case LoadingStatus.loading:
        return Semantics(
          label: semanticsFeedLoading,
          child: Container(),
        );
      case LoadingStatus.empty:
        return Semantics(
          label: semanticsFeedEmpty,
          child: Container(),
        );
      case LoadingStatus.failed:
        return Semantics(
          label: semanticsFeedLoadFailed,
          child: Container(),
        );
      case LoadingStatus.loaded:
        return const SizedBox();
      default:
        return constSizedBox(); }}}Copy the code

Here’s a quick tip about the Flutter monotest: Groups can be used to group groups of related use cases, which helps to categorize problems.

2. Thinking after the initial experience

Think: Can you write all three use cases at the beginning, and then write a unified implementation to pass all three at once?

Here, the current use cases are relatively simple, and the three states have strong correlation, but the states are different, so it is completely possible to write these three use cases first. How to control the granularity of separation? I think there’s a rule to follow: split tasks are focused enough that they don’t diverge easily.

For example, here are three use cases where the state is finite and therefore sufficiently focused; And suppose we one-time pulling down slide loading, refresh the single measurement all were written, the first such writing use cases out of thin air is hard to write, you can try yourself), then when we want to achieve through all the single measurement, we have to consider the boundary becomes very complex, it is easy to cause by A single test, B single test failed.

Continue to improve the function, add use cases: load successfully and the data is not empty, the list shows the corresponding data item

Write a single measurement

Consider: we expect to pass in A, B, C three data, after loading successfully, the page can display A, B, C three items. At this point, the previously designed input parameterStatusThat’s not enough, we also need to pass in a list, so let’s design it as a data class for nowFeedModel, which contains a state and a list. And because we need to verify that the page displays the corresponding item, we also need a list item build callback function

The single test code is as follows

 testWidgets("Load successful and data is not empty, list shows item of corresponding data", (tester) async {
    List<String> expectList = ["hello"."hi"."good"."bad"];
    List<String> actualList = [];
    FeedList feedList = FeedList<String>(
      feedModel: FeedModel(
        loadingStatus: LoadingStatus.loaded,
        listData: expectList,
      ),
      builder: (context, index, data) {
        actualList.add(data);
        returnContainer(); });await tester.pumpWidget(MaterialApp(home: feedList));

    expect(actualList.length, expectList.length, reason: "Actual data length is inconsistent with expected data length.");
    actualList.asMap().forEach((index, actualData) {
      expect(actualData, expectList[index], reason: "The actual data is not consistent with the expected data.");
    });
  });
Copy the code

The single test failed. Procedure

Write the minimum implementation version to pass a single test

This should not be too difficult to implement in order for the single test to pass, just take a ListView, use the incoming data as an input parameter, and call back the Builder.

class FeedModel<T> {
  final LoadingStatus loadingStatus;
  final List<T> listData;

  const FeedModel({
    required this.loadingStatus,
    this.listData = const[],}); }Copy the code
ListView.builder(
   itemBuilder: (context, index) {
      returnbuilder! .call(context, index, feedModel.listData[index]); } }, itemCount: feedModel.listData.length, )Copy the code

3. Your first taste of success

Add a use case: If there is a next page, slide to the last item to show loading more widgets

Use cases

testWidgets("When you swipe to the last item, if there's a next page, it shows you loading more widgets.", (tester) async {
    List<String> expectList = ["hello"."hi"."good"."bad"."last"];
    FeedList feedList = FeedList<String>(
      feedModel: FeedModel(
        loadingStatus: LoadingStatus.loaded,
        listData: expectList,
        hasNext: true,
      ),
      builder: (context, index, data) {
        // set height 100 to make sure list can scroll
        return SizedBox(height: 100, key: ValueKey(data)); });await tester.pumpWidget(MaterialApp(home: feedList));

    // scroll to the end
    var listFinder = find.byType(Scrollable);
    var lastItemFinder = find.byKey(const ValueKey("last"));
    await tester.scrollUntilVisible(lastItemFinder, 80, scrollable: listFinder);

    // should show load more widget
    var loadMoreFinder = find.bySemanticsLabel(FeedList.semanticsFeedLoadMore);
    expect(loadMoreFinder, findsOneWidget, reason: "No more widgets found to load");
  });
Copy the code

Single test failure

Write the minimum implementation version to pass a single test

Consider: entry needs to add a field, representing whether there is a next page; Also, when the list slides to the last item, a loading Widget is returned

parameter

class FeedModel<T> {
  final LoadingStatus loadingStatus;
  final List<T> listData;
  final bool hasNext;

  const FeedModel({
    required this.loadingStatus,
    this.hasNext = false.this.listData = const[],}); }Copy the code

The loading widget is fake data, so we need to add + 1 to the original data; If there is no next page, there is no need for fake data and loading widgets, so count is calculated as follows

var count = 0;
if (feedModel.listData.isEmpty) {
	count = 0;
} else if (feedModel.hasNext) {
	count = feedModel.listData.length + 1;
} else {
	count = feedModel.listData.length;
}
Copy the code

The ListView Builder code is as follows

ListView.builder(
   itemBuilder: (context, index) {
   	// Displays the Loading widget when sliding to the last item
   	if (index == count - 1) {
       return Semantics(
         label: semanticsFeedLoadMore,
         child: const SizedBox(height: 20)); }else {
      // Otherwise, call the builder function to build the normal item
      returnbuilder! .call(context, index, feedModel.listData[index]); } }, itemCount: count, )Copy the code

Thus, the use case you just wrote passes.

However, we found that the previous use case “load succeeded and data is not empty, list shows item of corresponding data” failed

As you can see, in the previous use case, we expected 4 build items, but there were only 3. Why is this?

Since we didn’t pass hasNext because of the previous use case, the default hasNext parameter is false. When hasNext is false, Count = feedModel. ListData. Length, in the case of 4, the ListView builder implementation, we determine when the index = = count – 1, Returning the loading widget instead of calling back the Builder parameter passed in, the Builder only called back three times, which caused the previous use case to fail.

So we just need to add one more judgment

This situation is very easy to occur in our daily development, when we develop new features, it is easy to ignore some boundaries or change the logic of the previous bad, when the single test can play its value, and if we strictly follow the DEVELOPMENT process of TDD, we can kill this bad case in the development process. It allows us to deliver more quality assured code

Thinking: Can code review easily find the problems that have just emerged?

4. Start adding complexity

Continue to add features:

  • Loading more Widgets should not be displayed after the loading is complete
  • After the slide up is complete, the new list is inserted at the end of the old list

From here, there is a certain degree of complexity. The previous use cases are basically Stateless, and the state is passed in through parameters, that is, the state is determined from the beginning and there is no possibility of change. Now, we needed to know when loading ended, introduced mutable state, and needed to do some validation after loading ended.

Consider: Since “load more” is triggered from within the list, if we want to know when the load ends, we have to get the loaded handle. In Dart, we usually useFutureSo we can think: we can pass in a return from the outsideFutureGets and fires from within the listFuture“So that we can judge from the outsideFutureWhen is it over?

This thought process is actually the construction process of testability, and TDD helps us write more testability code, and more testability code often means better design

Ignore the single-test code here (it’s not important here) and go straight to the implementation:

The incoming parameter adds an onLoadMore function that returns a Future

final Future<FeedModel<T>> Function()? onLoadMore;
Copy the code

Trigger the Future when the list slides to the last item

 _loadMore() async {
    if (widget.onLoadMore == null) {
      return;
    }
    var newFeedModel = awaitwidget.onLoadMore! (a); setState(() { feedModel = newFeedModel; }); }Copy the code

As you can see, there is a setState here, and in order to update the status after loading, you need to change the previously Stateless FeedList to a StatefuleWidget

5. First refactoring

The current FeedList is getting worse and worse. You need to pass in the first page of data and then load more futures. the data on the first page is a Future, but the data on the first page is handled externally, while the data on the second page is handled by the user

We said before, don’t refactor too early. The code that we’re refactoring or optimizing in the past is something that’s not very elegant, but this time the code that we’re refactoring is going to make a big difference to the framework, specifically the constructors. So if we get to this stage, if we don’t make some changes, then many of the subsequent use cases, including some of the earlier ones, will probably be scrapped.

Now is a good time to refactor

Refactoring: Simplified constructors, unified first load and more load, load timing is handled internally

The input parameter is reconstructed into two:

final DataWidgetBuilder<T>? builder; // Build the item callback
final Future<FeedModel<T>> Function(int) onLoadMore; // Load more Future functions for the first time. The argument represents the current list offset, which can be used to distinguish the number of loads
Copy the code

Code implementation

/ /... Omit irrelevant code
class FeedList<T> extends StatefulWidget {
 / /... Omit irrelevant code
  final DataWidgetBuilder<T>? builder;
  final Future<FeedModel<T>> Function(int) onLoadMore;
/ /... Omit irrelevant code
}

class _FeedListState<T> extends State<FeedList<T>> {
  bool isFirstLoad = true;
  late FeedModel feedModel;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      	// Comment 1: If the first page is loaded, trigger onLoadMore directly and pass the returned Future to FutureBuilder; If it's not the first page, return null to FutureBuilder, at which point the code goes to the else branch, comment 2
        future: isFirstLoad ? widget.onLoadMore(0) : null,
        builder: (BuildContext context, AsyncSnapshot<dynamic> snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
           / /... Omit irrelevant code
          } else if (snapshot.hasError) {
           / /... Omit irrelevant code
          } else {
            // Note 2: Instead of loading the first page, the code will go here because null was passed to FutureBuilder
            
            // Note 3: If the first page is loaded, use the value in snapshot, otherwise use the value of state
            if(isFirstLoad && snapshot.data ! =null) {
              feedModel = snapshot.data as FeedModel;
            }
            / /... Omit irrelevant code
            if (feedModel.listData.isEmpty) {
              / /... Omit irrelevant code
            } else {
              returnSemantics( label: FeedList.semanticsFeedLoaded, child: widget.builder ! =null
                    ? ListView.builder(
                        itemBuilder: (context, index) {
                          // has next and reach to end, show loading more widget
                          if (feedModel.hasNext && index == count - 1) {
                            _loadMore(index);
                            return Semantics(
                              label: FeedList.semanticsFeedLoadMore,
                              child: const SizedBox(
                                height: 500,),); }return widget.builder!
                              .call(context, index, feedModel.listData[index]);
                        },
                        itemCount: count,
                      )
                    : constSizedBox(), ); }}}); } _loadMore(int curIndex) async {
    var newFeedModel = await widget.onLoadMore(curIndex);
    setState(() {
      // Note 4: Update the feedModel value when loading more
      feedModel = FeedModel(
        hasNext: newFeedModel.hasNext,
        listData: [...feedModel.listData, ...newFeedModel.listData],
      );
      isFirstLoad = false; }); }}Copy the code

FutureBuilder is used to load the first page of data (see note 1), and isFirstLoad is used to indicate whether the first page is loaded. When the trigger loads more, isFirstLoad is set to false and the new feedModel is updated, the list renders the list with the new data (see note 4).

As you can see, the reasonable than before after refactoring is a lot of, but still not enough grace, such as load every time more is rebuilding the widget, it brings a lot of unnecessary reconstruction, but here we no longer worry also continue to refactor, the purpose is to let our constructor to simplify, subsequent refactoring is modified, It doesn’t cause a big change in construction, so it can be dealt with later

Since this reconstruction modified the construction parameters, so the previous single test also had to do a relatively large reconstruction to be able to run again.

6. Second refactoring – Feel the benefits of TDD again

After that, the writing of the use cases was basically smooth, and I will not list them here. When all the functions were basically completed, I did another reconstruction. This time, I replaced FutureBuilder with StreamBuilder, in order to reduce unnecessary redrawing and make the code logic more unified. Since I only refactored the concrete implementation this time, you can see that I changed the implementation code a lot, but left the single-side code largely untouched

A screenshot of part of the reconstructed Diff

Single test basically unchanged

After the transformation is complete, all previous use cases pass

Although refactoring changed a lot of code, I was reassured by the single test results

7. Clear the air

  • Without feeling the value of TDD, TDD breaks with conventional development thinking

    1. The value is very obvious, there is a single test, to achieve, so that every time the code has a single test protection
    2. TDDThe development process helps us design smarter code and focuses us on doing one thing at a time
  • Think TDD tedious, clearly can be implemented in one breath of code, why do we have to break down

    1. Same as above,TDDLead us to split tasks properly
    2. Taking apart tasks helps us focus on one thing at a time
  • Write use cases first, but don’t know how to design use cases

    The process of designing use cases is the process of dismantling tasks. At the same time, it is necessary to think about how to design code to be more measurable. Usually, the structure and responsibilities of the code with testability are clearer

  • Think your use case is silly and doesn’t feel useful

    Not all code needs to be tested. For example, we don’t need to verify that a Text widget passed “Hello” actually shows the word “Hello”. For example, we don’t need to validate a code segment that doesn’t have any logical branches, etc.

  • I wrote the code logic is very simple, there will be no problem, no need to write a single test

    Sometimes the current logic may be simple, but as the business grows, it may be possible to extend a lot of functionality and add more complex logical judgments in the future, when the less valuable single test written earlier can come into play

  • It says, “You found the previous use case doesn’t seem right, want to use it instead?

    1. It makes sense that beta code is also code and will undergo refactoring;
    2. However, if the code under test does not have a relatively large reconstruction, the single test code should be relatively stable, otherwise it needs to consider whether the previous single test is written reasonably
    3. Mock as little as possible for single testing and try to avoid relying too much on concrete implementations if strictly executedTDDThe process, that is, write the single test first, then write the implementation, basically avoids the above problems
  • How to disassemble use cases? How to control granularity?

    Split tasks should follow: enough focus, not easy to diverge

  • When do you refactor?

    1. TDDThe process should not be refactoring early. When we find that the code is not easy to extend and we need to make major structural changes, such as the constructor changes, we can start refactoring. This refactoring is usually accompanied by refactoring of the single-test code.
    2. When we reach a stable stage of function development and want to refactor specific implementation, such as performance optimization and code logic optimization, we do not need to modify the single test at this time, which can help us verify whether the refactoring is safe and robust

8. Display of finished products

All the code covered in this article can be viewed here

All use case sorting:

Implementation code: feed_list.dart

Single test code: feed_test.dart

Coverage: