Skip to content

[ios][tools] A design issue in xcode_backend.dart resulted in poor testability #173133

@hellohuanlin

Description

@hellohuanlin

Use case

The xcode_backend.dart mainly contains a Context class, and the current design is:

// xcode_backend.dart
class Context {
  void func1() { ... }
  void func2() { ... }
}

// xcode_backend_test.dart (unit tests in general.shard)
class TestContext {
  @override
  void func1() { ... }
  @override
  void func2() { ... }
}

For example, one of such functions is runSync:

/// Run given command in a synchronous subprocess.
///
/// Will throw [Exception] if the exit code is not 0.
ProcessResult runSync(
String bin,
List<String> args, {
bool verbose = false,
bool allowFail = false,
String? workingDirectory,
}) {
if (verbose) {
print('♦ $bin ${args.join(' ')}');
}
final ProcessResult result = Process.runSync(bin, args, workingDirectory: workingDirectory);
if (verbose) {
print((result.stdout as String).trim());
}
final String resultStderr = result.stderr.toString().trim();
if (resultStderr.isNotEmpty) {
final errorOutput = StringBuffer();
if (result.exitCode != 0) {
// "error:" prefix makes this show up as an Xcode compilation error.
errorOutput.write('error: ');
}
errorOutput.write(resultStderr);
echoError(errorOutput.toString());
// Stream stderr to the Flutter build process.
// When in verbose mode, `echoError` above will show the logs. So only
// stream if not in verbose mode to avoid duplicate logs.
// Also, only stream if exitCode is 0 since errors are handled separately
// by the tool on failure.
if (!verbose && exitCode == 0) {
streamOutput(errorOutput.toString());
}
}
if (!allowFail && result.exitCode != 0) {
throw Exception('Command "$bin ${args.join(' ')}" exited with code ${result.exitCode}');
}
return result;
}

And it's overwritten here:

@override
ProcessResult runSync(
String bin,
List<String> args, {
bool verbose = false,
bool allowFail = false,
String? workingDirectory,
}) {
return processManager.runSync(
<dynamic>[bin, ...args],
workingDirectory: workingDirectory,
environment: environment,
);
}

This means during unit test, the whole chunk of actual implementation is stubbed out, which defeats the whole purpose of unit tests! For unit test, we should stub out the dependencies of the test subject, not the implementation of test subject itself.

Proposal

Quick fix (short term)

Context can instead introduce a 1-line wrapper function that only contains https://github.com/flutter/flutter/blob/5483fef5384821748cfcc1bfc3157e1f9c0322ea/packages/flutter_tools/bin/xcode_backend.dart#L118C34-L118C41

Then TestContext will just override this wrapper function. Then the rest of the implementation would be run during unit test.

Actual fix

The fact that Context is only inherited by TestContext in tests is an indicator that inheritance isn't the best approach here. We should instead favor composition, by injecting dependencies (e.g. ProcessManager).

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listplatform-iosiOS applications specificallyteam-iosOwned by iOS platform teamtoolAffects the "flutter" command-line tool. See also t: labels.triaged-iosTriaged by iOS platform team

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions