How to Use Selenium for API Testing

Imagine an API that is being used by millions of clients, say the Google Maps API suddenly stopped working, how many businesses or users would be affected? What would be the net effect on costs to businesses? Your guess is as good as mine, enormous and painstaking! This is why API testing is paramount for developers.

Testing of your APIs after development is both a daunting task and a satisfying experience if all facets of the tests are captured and working as intended. API testing is the process of making sure that your API is doing what it's built to do, while also handling its assigned load. Tests are also imperative to ensure that the API works across multiple browsers and devices and reduce cost pains in case anything goes wrong.

Selenium is a web testing tool that is open source. It's used to automate tests carried out on web browsers. These tests can be carried out on multiple web browsers eliminating the risk of otherwise manual repetitive error-prone testing processes.

In this tutorial, you will learn the importance of testing your APIs and using Selenium as a tool to automate this otherwise tedious process.

Let's kick off the party by building a simple API in the next paragraph.

Example API

You are going to use Kotlin (Java is also okay) as the preferred language and Springboot. Then later we will automate the testing with selenium.

You will create a simple REST API for your User class that has a few fields namely, ID, username, and email. To kickstart, navigate to your browser and search for start. spring and fill it appropriately by giving the name of the project, the language to be used et al as shown below.

spring.png

For dependencies, because you are creating a RESTful API, select the Spring web module as shown:

spring-web-module.png

Select the spring boot dev tools modules too:

spring-devtools.png

For the SQL database, select the Spring data JPA module:

spring-data.png

For testing purposes, you will need the H2 in-memory database. Select its module too:

h2-db.png

That sums up all the dependencies you will need for starters. Click generate to download and save your Spring sample project. Unzip the downloaded file then import it into your IntelliJ IDEA.

Now let's write some code...

First, you will have to structure the code into packages as shown below. The names are self-explanatory.

packages.png

Paste the code below into the DAO package.

@Repository
interface IUserDao: JpaRepository<User, Int> {
}

Here, you have an interface that extends JPARepository, which is a class offered by Springboot. Next, create a User data class in the domain package:

@Entity
data class User(
    @Id
    @SequenceGenerator(
        name = USER_SEQUENCE ,
        sequenceName = USER_SEQUENCE,
        initialValue = 1,
        allocationSize = 1)

    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = USER_SEQUENCE)
    @Column(name = "user_id")
    val id : Int = 1,
    @Column(name = "username")
    var username : String?,
    @Column(name = "email")
    var email : String?
) {

    companion object{
        const val USER_SEQUENCE = "USER_SEQUENCE"
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || Hibernate.getClass(this) != Hibernate.getClass(other)) return false
        other as User

        return id == other.id
    }

    override fun hashCode(): Int = 562048007

    @Override
    override fun toString(): String {
        return this::class.simpleName + "(id = $id , username = $username , email = $email )"
    }
}

Your data transfer objects package (DTO) will house the following classes.

  • Add user request class for adding users.

  • A error response class for capturing errors in real-time.

  • A class to update users.

  • And finally, a user response class.

Create the following classes that represent the classes we mentioned above in your dto package.

class AddUserRequest(
    val username : String?,
    val email : String?
)
data class ErrorResponse(
    val error : String = "Bad request",
    val message : String,
    val date : LocalDateTime = LocalDateTime.now()
)
class UpdateUserRequest(
    val id : Int,
    val username : String?,
    val email : String?
) {
}
class UserResponse(
    val id: Int,
    val username : String,
    val email : String
) {
}

Then you will need to deal with how to map your User to AddUserRequest. For this, you will create an interface in the mapper package:

interface IMapperContract<Entity , Domain> {

    fun mapToDomain(entity: Entity) : Domain
}

And a class that implements this interface:

@Component
class AddUserRequestMapper : IMapperContract<AddUserRequest , User> {

    override fun mapToDomain(entity: AddUserRequest): User {
        return User(
            username = entity.username,
            email = entity.email
        )
    }

}

Next is your resource package. This will house an interface with all the functions that will aid in CRUD. Which is basically creating, reading, updating, deleting all users in the database.

First, you will create an interface:

interface IUserResource {

    /*
    * The ResponseEntity is a spring framework object that allows us to modify the HTTP calls by the headers etc
    * in the controller.
    * */

    fun findUserById(userId: Int) : ResponseEntity<UserResponse?>

    fun findAllUsers(pageable: Pageable) : ResponseEntity<Page<UserResponse>>

    fun saveUser(addUserRequest: AddUserRequest) : ResponseEntity<UserResponse?>

    fun updateUser(updateUserRequest: UpdateUserRequest) : ResponseEntity<UserResponse?>

    fun deleteUserById(userId : Int) : ResponseEntity<Unit>
}

Note that we are using the Pageable class offered on the fly by the spring framework for retrieving all users.

Next, you will create a class that implements the interface above:

@RestController
@RequestMapping(value = [BASE_URL]) //specifies the path
class IUserResourceImpl(
    private val userManagementService: IUserManagementService
) : IUserResource {

    @GetMapping("/{userId}")
    override fun findUserById(@PathVariable userId: Int): ResponseEntity<UserResponse?> {
        val userResponse : UserResponse? = userManagementService.findUserById(userId)
        return ResponseEntity.status(HttpStatus.OK).body(userResponse)
    }

    @GetMapping
    override fun findAllUsers(pageable: Pageable): ResponseEntity<Page<UserResponse>> {
        return ResponseEntity.ok(userManagementService.findAllUsers(pageable))
    }

    @PostMapping
    override fun saveUser(@RequestBody addUserRequest: AddUserRequest): ResponseEntity<UserResponse?> {
        val userResponse = userManagementService.saveUser(addUserRequest)
        return ResponseEntity.created(URI.create(BASE_URL.plus("/${userResponse.id}"))).body(userResponse)
    }

    @PutMapping
    override fun updateUser(@RequestBody updateUserRequest: UpdateUserRequest): ResponseEntity<UserResponse?> {
        return ResponseEntity.ok(userManagementService.updateUser(updateUserRequest))
    }

    @DeleteMapping("/{userId}")
    override fun deleteUserById(@PathVariable userId: Int): ResponseEntity<Unit> {
        userManagementService.deleteUserById(userId)
        return ResponseEntity.noContent().build()
    }

    companion object {
        const val BASE_URL = "/demo/api/v1/user"
    }
}

Also, note that we have a companion object which will house the base URL as a constant. This base URL can be any URL you choose, user is the ENDPOINT.

Next, create an interface in the service package. This interface will have an implementation that is similar to our resources class except that it also takes care of mapping our User class to AddUserRequest class as shown below:

interface IUserManagementService {

    fun findUserById(userId: Int) : UserResponse?

    fun findAllUsers(pageable: Pageable) : Page<UserResponse>

    fun saveUser(addUserRequest: AddUserRequest) : UserResponse

    fun updateUser(updateUserRequest: UpdateUserRequest) : UserResponse

    fun deleteUserById(userId : Int)
}

And its implementation:

@Service
class UserManagementServiceImpl(
    private val userDao: IUserDao,
    private val addUserRequestMapper: AddUserRequestMapper
) : IUserManagementService {

    override fun findUserById(userId: Int): UserResponse? {
       return getUserById(userId).toUserResponse()
    }

    override fun findAllUsers(pageable: Pageable): Page<UserResponse> {
        return userDao.findAll(pageable).map(User?::toUserResponse)
    }

    override fun saveUser(addUserRequest: AddUserRequest): UserResponse {
        return this.saveOrUpdateUser(
            addUserRequestMapper.mapToDomain(addUserRequest)
        )
    }

    override fun updateUser(updateUserRequest: UpdateUserRequest): UserResponse {
       val updatedUser : User = (getUserById(updateUserRequest.id) ?: throw IllegalStateException(
           "${updateUserRequest.id} not found!"))

       return this.saveOrUpdateUser(
            updatedUser.apply {
                email = updateUserRequest.email
                username = updateUserRequest.username
            }
        )
    }

    override fun deleteUserById(userId: Int) = userDao.deleteById(userId)

    private fun getUserById(userId: Int?) : User? = userDao.findByIdOrNull(userId)

    private fun saveOrUpdateUser(user: User) : UserResponse = userDao.save(user).toUserResponse()
}

Lastly, you will also have a utility package that will house all classes and functions that will be used across the whole program.

In this UTILS package, we will have a class that will log errors:

@ControllerAdvice
class ErrorLogger {

    fun handleIllegalStateException(e: IllegalStateException) : ResponseEntity<ErrorResponse>{
        return ResponseEntity
            .badRequest()
            .body(ErrorResponse(message = e.localizedMessage))
    }
}

And an extension function (the reason we love Kotlin!) that will map your User data class to UserResponse class:

fun User?.toUserResponse() : UserResponse{
    return UserResponse(
        id = this?.id ?: 1,
        username = this?.username ?: "John Doe",
        email = this?.email ?: "jdoe@gmail.com"
    )
}

That's it! Our simple API is almost complete, you will just need a Database to store your users.

For this, create a package in the root of your project directory and call it anything, or resource. Then create a YAML file and add the code below:

spring:
  output:
    ansi:
      enabled: ALWAYS
  datasource:
    url: jdbc:h2:file:${user.home}/IdeaProjects/spring-kotlin-demo/db
    username: username
    password: username
  jpa:
    hibernate:
      ddl-auto: 'create-drop'

logging:
  level:
    org:
      hibernate:
        SQL: DEBUG
        type:
          descriptor:
            sql:
              BasicBinder: TRACE
server:
  port: 8090

In this demo, you will use an H2 database, an in-memory database provided by the spring framework. This will not lose our items when we close the application.

That's about it, lets explore how to test the API endpoints that we have just created next.

Creating a functional test suite using Selenium.

Selenium is a web-based UI testing tool, until the advent of selenium 4, there was no support for testing URL-based APIs. This made it possible to test APIS and automate the validation of the endpoints whenever they are hit.

To kickstart your rollercoaster with Selenium, head to your browser and search for selenium maven dependency.

Select selenium java, navigate to version 4, choose alpha-7, and copy the dependencies:

<!-- https://mvnrepository.com/artifact/org.seleniumhq.selenium/selenium-java -->
<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-java</artifactId>
    <version>4.0.0-alpha-7</version>
</dependency>

Do the same for testNG latest stable version:

<!-- https://mvnrepository.com/artifact/org.testng/testng -->
<dependency>
    <groupId>org.testng</groupId>
    <artifactId>testng</artifactId>
    <version>7.4.0</version>
    <scope>test</scope>
</dependency>

Add these dependencies in your pom.xml file.

Selenium 4 alpha offers the devTools method on the fly. Create a class in your Utils package that will do the following:

  • Declare devtools.

  • Capture HTTP requests.

  • Create a session.

  • Set up a Chrome driver that you will have to download from here, unzip and paste it in a drivers folder in your project.

  • Create a CDP command that will enable capturing network logs or traffic and,

  • Attach a listener to the dev tools and print ensuing results as shown:

open class TestApiEndpoints {
    lateinit var devTools: DevTools
    private lateinit var driver: ChromeDriver

    fun captureHttpRequests() {
        setUpDriver()
        devTools = driver.getDevTools()
        devTools.createSession()
        devTools.send(Network.enable(
                    Optional.empty(),
                    Optional.empty(),
                    Optional.empty()))
        devTools.addListener(Network.requestWillBeSent()) { entry: RequestWillBeSent ->
            println("Request URL is " + entry.request.url)
            println("Request type is " + entry.request.method)
        }

        driver.manage().window().maximize()
        driver[BASE_URL]
        driver.manage().timeouts().implicitlyWait(10, TimeUnit.SECONDS)
        devTools.send(Network.disable());
    }

    private fun setUpDriver() {
        System.setProperty(
            "webdriver.chrome.driver",
            System.getProperty("user.dir") + File.separator + "drivers" + File.separator + "chromedriver"
        )
        driver = ChromeDriver()
    }
}

Best features of Selenium

  • The biggest feature of selenium would be the fact that it's open-source and free for all to use. This implies a reduced cost when building large-scale functional test suites for your APIs. The support for the framework is also available completely for free.

  • Selenium has a large user base that is instrumental in providing blockers, maintaining the framework at no cost, and offering a platform for easy learning and implementation.

  • It's relatively easy to implement and allows users to creatively customize it for their personal use cases.

  • Selenium can be used across multiple devices with almost no glitches at all. This offers users and developers the confidence that they will not have to rewrite test cases per device.

  • Selenium supports multiple frameworks and a wide array of languages. Frameworks such as Maven, Ant, and Gradle can easily be integrated with it, notable note is its easy integration with test frameworks such as TestNG for automation testing and works across many languages.

Conclusion

To wrap it all up, you have learned the importance of testing APIs and leveraging Selenium as a tool for automating this process. We built a simple API that adds users and performs all CRUD actions, we then proceeded to make a functional test suite that incorporates selenium. As developers, testing APIs has been daunting and proved costly in the long run especially if done manually. But we can automate this process using tools such as Selenium, with this, you will be better armed going forward!