- Espresso mock network interceptor added (#9)

- Ability to stub end points in UI tests
This commit is contained in:
2022-04-03 14:50:30 +01:00
committed by GitHub
parent a1a32e4ceb
commit a96b976e86
27 changed files with 737 additions and 66 deletions

Binary file not shown.

View File

@@ -17,7 +17,7 @@ android {
targetSdkVersion 30 targetSdkVersion 30
versionCode 5 versionCode 5
versionName "3.0" versionName "3.0"
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' testInstrumentationRunner "com.appttude.h_mal.atlas_weather.application.TestRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
buildConfigField "String", "ParamOne", "${paramOneEndPoint}" buildConfigField "String", "ParamOne", "${paramOneEndPoint}"

View File

@@ -0,0 +1,47 @@
package com.appttude.h_mal.atlas_weather.application
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.idling.CountingIdlingResource
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.data.location.MockLocationProvider
import com.appttude.h_mal.atlas_weather.data.network.Api
import com.appttude.h_mal.atlas_weather.data.network.NetworkModule
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
import com.appttude.h_mal.atlas_weather.data.network.interceptors.MockingNetworkInterceptor
import com.appttude.h_mal.atlas_weather.data.network.interceptors.NetworkConnectionInterceptor
import com.appttude.h_mal.atlas_weather.data.network.interceptors.QueryParamsInterceptor
import com.appttude.h_mal.atlas_weather.data.network.networkUtils.loggingInterceptor
import java.io.BufferedReader
class TestAppClass : BaseAppClass() {
private val idlingResources = CountingIdlingResource("Data_loader")
private val mockingNetworkInterceptor = MockingNetworkInterceptor(idlingResources)
override fun onCreate() {
super.onCreate()
IdlingRegistry.getInstance().register(idlingResources)
}
override fun createNetworkModule(): Api {
return NetworkModule().invoke<WeatherApi>(
NetworkConnectionInterceptor(this),
QueryParamsInterceptor(),
loggingInterceptor,
mockingNetworkInterceptor
)
}
override fun createLocationModule() = MockLocationProvider()
fun stubUrl(url: String, rawPath: String) {
val id = resources.getIdentifier(rawPath, "raw", packageName)
val iStream = resources.openRawResource(id)
val data = iStream.bufferedReader().use(BufferedReader::readText)
mockingNetworkInterceptor.addUrlStub(url = url, data = data)
}
fun removeUrlStub(url: String){
mockingNetworkInterceptor.removeUrlStub(url = url)
}
}

View File

@@ -0,0 +1,21 @@
package com.appttude.h_mal.atlas_weather.application
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
class TestRunner : AndroidJUnitRunner() {
@Throws(
InstantiationException::class,
IllegalAccessException::class,
ClassNotFoundException::class
)
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(cl, TestAppClass::class.java.name, context)
}
}

View File

@@ -0,0 +1,15 @@
package com.appttude.h_mal.atlas_weather.data.location
import com.appttude.h_mal.atlas_weather.model.types.LocationType
class MockLocationProvider : LocationProvider {
private val latLong = Pair(0.00, 0.00)
override suspend fun getCurrentLatLong() = latLong
override fun getLatLongFromLocationName(location: String) = latLong
override suspend fun getLocationNameFromLatLong(lat: Double, long: Double, type: LocationType): String {
return "Mock Location"
}
}

View File

@@ -0,0 +1,46 @@
package com.appttude.h_mal.atlas_weather.data.network.interceptors
import androidx.test.espresso.idling.CountingIdlingResource
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
class MockingNetworkInterceptor(
private val idlingResource: CountingIdlingResource
) : Interceptor {
private var feedMap: MutableMap<String, String> = mutableMapOf()
private var urlCallTracker: MutableMap<String, Int> = mutableMapOf()
override fun intercept(chain: Interceptor.Chain): Response {
idlingResource.increment()
val original = chain.request()
val originalHttpUrl = original.url.toString().split("?")[0]
urlCallTracker.computeIfPresent(originalHttpUrl) { _, j ->
j + 1
}
feedMap[originalHttpUrl]?.let { jsonPath ->
val body = jsonPath.toResponseBody("application/json".toMediaType())
val chainResponseBuilder = Response.Builder()
.code(200)
.protocol(Protocol.HTTP_1_1)
.request(original)
.message("OK")
.body(body)
idlingResource.decrement()
return chainResponseBuilder.build()
}
idlingResource.decrement()
return chain.proceed(original)
}
fun addUrlStub(url: String, data: String) = feedMap.put(url, data)
fun removeUrlStub(url: String) = feedMap.remove(url)
}

View File

@@ -0,0 +1,11 @@
package com.appttude.h_mal.atlas_weather.monoWeather.robot
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.utils.BaseTestRobot
fun homeScreen(func: HomeScreenRobot.() -> Unit) = HomeScreenRobot().apply { func() }
class HomeScreenRobot : BaseTestRobot() {
fun verifyCurrentTemperature(temperature: Int) = matchText(R.id.temp_main_4, temperature.toString())
fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
fun refresh() = pullToRefresh(R.id.swipe_refresh)
}

View File

@@ -0,0 +1,39 @@
package com.appttude.h_mal.atlas_weather.monoWeather.testsuite
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import com.appttude.h_mal.atlas_weather.application.TestAppClass
import com.appttude.h_mal.atlas_weather.monoWeather.ui.MainActivity
import com.appttude.h_mal.atlas_weather.utils.Stubs
import org.junit.After
import org.junit.Rule
open class BaseTest() {
lateinit var testApp: TestAppClass
@Rule
@JvmField
var mActivityTestRule: ActivityTestRule<MainActivity> = object : ActivityTestRule<MainActivity>(MainActivity::class.java) {
override fun beforeActivityLaunched() {
super.beforeActivityLaunched()
testApp = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as TestAppClass
setupFeed()
}
}
fun stubEndpoint(url: String, stub: Stubs) {
testApp.stubUrl(url, stub.id)
}
fun unstubEndpoint(url: String) {
testApp.removeUrlStub(url)
}
@After
fun tearDown() {
}
open fun setupFeed() {}
}

View File

@@ -0,0 +1,29 @@
package com.appttude.h_mal.atlas_weather.monoWeather.testsuite
import androidx.test.rule.GrantPermissionRule
import com.appttude.h_mal.atlas_weather.monoWeather.robot.homeScreen
import com.appttude.h_mal.atlas_weather.utils.Stubs
import org.junit.Rule
import org.junit.Test
class HomePageUITest : BaseTest() {
@Rule
@JvmField
var mGrantPermissionRule: GrantPermissionRule =
GrantPermissionRule.grant(
"android.permission.ACCESS_COARSE_LOCATION")
override fun setupFeed() {
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Valid)
}
@Test
fun loadApp_validWeatherResponse_returnsValidPage() {
homeScreen {
verifyCurrentTemperature(2)
verifyCurrentLocation("Mock Location")
}
}
}

View File

@@ -0,0 +1,51 @@
package com.appttude.h_mal.atlas_weather.utils
import android.view.View
import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.ViewInteraction
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.swipeDown
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.*
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.anything
import org.hamcrest.Matcher
open class BaseTestRobot {
fun fillEditText(resId: Int, text: String): ViewInteraction =
onView(withId(resId)).perform(ViewActions.replaceText(text), ViewActions.closeSoftKeyboard())
fun clickButton(resId: Int): ViewInteraction = onView((withId(resId))).perform(ViewActions.click())
fun textView(resId: Int): ViewInteraction = onView(withId(resId))
fun matchText(viewInteraction: ViewInteraction, text: String): ViewInteraction = viewInteraction
.check(ViewAssertions.matches(ViewMatchers.withText(text)))
fun matchText(resId: Int, text: String): ViewInteraction = matchText(textView(resId), text)
fun clickListItem(listRes: Int, position: Int) {
onData(anything())
.inAdapterView(allOf(withId(listRes)))
.atPosition(position).perform(ViewActions.click())
}
fun pullToRefresh(resId: Int){
onView(allOf(withId(resId), isDisplayed())).perform(swipeDown())
}
fun waitFor(delay: Long): ViewAction? {
return object : ViewAction {
override fun getConstraints(): Matcher<View> = isRoot()
override fun getDescription(): String = "wait for $delay milliseconds"
override fun perform(uiController: UiController, v: View?) {
uiController.loopMainThreadForAtLeast(delay)
}
}
}
}

View File

@@ -0,0 +1,3 @@
package com.appttude.h_mal.atlas_weather.utils
val FALLBACK_TIME: Long = 0L

View File

@@ -0,0 +1,8 @@
package com.appttude.h_mal.atlas_weather.utils
enum class Stubs(
val id: String
) {
Valid("valid_response"),
Invalid("invalid_response")
}

View File

@@ -1,8 +1,9 @@
package com.appttude.h_mal.atlas_weather.application package com.appttude.h_mal.atlas_weather.application
import android.app.Application import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
import androidx.test.espresso.idling.CountingIdlingResource
import com.appttude.h_mal.atlas_weather.data.location.LocationProviderImpl import com.appttude.h_mal.atlas_weather.data.location.LocationProviderImpl
import com.appttude.h_mal.atlas_weather.data.network.Api
import com.appttude.h_mal.atlas_weather.data.network.NetworkModule
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
import com.appttude.h_mal.atlas_weather.data.network.interceptors.NetworkConnectionInterceptor import com.appttude.h_mal.atlas_weather.data.network.interceptors.NetworkConnectionInterceptor
import com.appttude.h_mal.atlas_weather.data.network.interceptors.QueryParamsInterceptor import com.appttude.h_mal.atlas_weather.data.network.interceptors.QueryParamsInterceptor
@@ -15,7 +16,6 @@ import com.appttude.h_mal.atlas_weather.helper.ServicesHelper
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
import com.google.gson.Gson import com.google.gson.Gson
import org.kodein.di.Kodein import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.androidXModule import org.kodein.di.android.x.androidXModule
import org.kodein.di.generic.bind import org.kodein.di.generic.bind
import org.kodein.di.generic.instance import org.kodein.di.generic.instance
@@ -24,30 +24,16 @@ import org.kodein.di.generic.singleton
const val LOCATION_PERMISSION_REQUEST = 505 const val LOCATION_PERMISSION_REQUEST = 505
class AppClass : Application(), KodeinAware { class AppClass : BaseAppClass() {
companion object { override fun createNetworkModule(): Api {
// idling resource to be used for espresso testing return NetworkModule().invoke<WeatherApi>(
// when we need to wait for async operations to complete NetworkConnectionInterceptor(this),
val idlingResources = CountingIdlingResource("Data_loader") QueryParamsInterceptor(),
loggingInterceptor
)
} }
// Kodein creation of modules to be retrieve within the app override fun createLocationModule() = LocationProviderImpl(this)
override val kodein = Kodein.lazy {
import(androidXModule(this@AppClass))
bind() from singleton { Gson() }
bind() from singleton { NetworkConnectionInterceptor(instance()) }
bind() from singleton { QueryParamsInterceptor() }
bind() from singleton { loggingInterceptor }
bind() from singleton { WeatherApi("https://api.openweathermap.org/data/2.5/", instance(), instance(), instance()) }
bind() from singleton { AppDatabase(instance()) }
bind() from singleton { PreferenceProvider(instance()) }
bind() from singleton { RepositoryImpl(instance(), instance(), instance()) }
bind() from singleton { SettingsRepositoryImpl(instance()) }
bind() from singleton { LocationProviderImpl(instance()) }
bind() from singleton { ServicesHelper(instance(), instance(), instance()) }
bind() from provider { ApplicationViewModelFactory(instance(), instance()) }
}
} }

View File

@@ -0,0 +1,49 @@
package com.appttude.h_mal.atlas_weather.application
import android.app.Application
import androidx.test.espresso.idling.CountingIdlingResource
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.location.LocationProviderImpl
import com.appttude.h_mal.atlas_weather.data.network.Api
import com.appttude.h_mal.atlas_weather.data.network.NetworkModule
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
import com.appttude.h_mal.atlas_weather.data.network.interceptors.NetworkConnectionInterceptor
import com.appttude.h_mal.atlas_weather.data.network.interceptors.QueryParamsInterceptor
import com.appttude.h_mal.atlas_weather.data.network.networkUtils.loggingInterceptor
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
import com.appttude.h_mal.atlas_weather.data.repository.RepositoryImpl
import com.appttude.h_mal.atlas_weather.data.repository.SettingsRepositoryImpl
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
import com.appttude.h_mal.atlas_weather.helper.ServicesHelper
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
import com.google.gson.Gson
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.androidXModule
import org.kodein.di.generic.bind
import org.kodein.di.generic.instance
import org.kodein.di.generic.provider
import org.kodein.di.generic.singleton
abstract class BaseAppClass : Application(), KodeinAware {
// Kodein creation of modules to be retrieve within the app
override val kodein = Kodein.lazy {
import(androidXModule(this@BaseAppClass))
bind() from singleton { createNetworkModule() as WeatherApi}
bind() from singleton { createLocationModule() }
bind() from singleton { Gson() }
bind() from singleton { AppDatabase(instance()) }
bind() from singleton { PreferenceProvider(instance()) }
bind() from singleton { RepositoryImpl(instance(), instance(), instance()) }
bind() from singleton { SettingsRepositoryImpl(instance()) }
bind() from singleton { ServicesHelper(instance(), instance(), instance()) }
bind() from provider { ApplicationViewModelFactory(instance(), instance()) }
}
abstract fun createNetworkModule() : Api
abstract fun createLocationModule() : LocationProvider
}

View File

@@ -80,8 +80,6 @@ class LocationProviderImpl(
handlerThread.start() handlerThread.start()
// Now get the Looper from the HandlerThread // Now get the Looper from the HandlerThread
// NOTE: This call will block until the HandlerThread gets control and initializes its Looper // NOTE: This call will block until the HandlerThread gets control and initializes its Looper
// Now get the Looper from the HandlerThread
// NOTE: This call will block until the HandlerThread gets control and initializes its Looper
val looper = handlerThread.looper val looper = handlerThread.looper
return suspendCoroutine { cont -> return suspendCoroutine { cont ->

View File

@@ -0,0 +1,3 @@
package com.appttude.h_mal.atlas_weather.data.network
interface Api

View File

@@ -0,0 +1,23 @@
package com.appttude.h_mal.atlas_weather.data.network
import com.appttude.h_mal.atlas_weather.data.network.networkUtils.buildOkHttpClient
import com.appttude.h_mal.atlas_weather.data.network.networkUtils.createRetrofit
import okhttp3.Interceptor
open class BaseNetworkModule {
// Declare the method we want/can change (no annotations)
open fun baseUrl() = "/"
inline fun <reified T: Api> invoke(
vararg interceptors: Interceptor
): Api {
val okHttpClient = buildOkHttpClient(*interceptors)
return createRetrofit(
baseUrl(),
okHttpClient,
T::class.java
)
}
}

View File

@@ -0,0 +1,6 @@
package com.appttude.h_mal.atlas_weather.data.network
class NetworkModule : BaseNetworkModule() {
override fun baseUrl(): String = "https://api.openweathermap.org/data/2.5/"
}

View File

@@ -1,17 +1,12 @@
package com.appttude.h_mal.atlas_weather.data.network package com.appttude.h_mal.atlas_weather.data.network
import com.appttude.h_mal.atlas_weather.data.network.interceptors.NetworkConnectionInterceptor
import com.appttude.h_mal.atlas_weather.data.network.interceptors.QueryParamsInterceptor
import com.appttude.h_mal.atlas_weather.data.network.networkUtils.buildOkHttpClient
import com.appttude.h_mal.atlas_weather.data.network.networkUtils.createRetrofit
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Response import retrofit2.Response
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Query import retrofit2.http.Query
interface WeatherApi { interface WeatherApi: Api {
@GET("onecall?") @GET("onecall?")
suspend fun getFromApi( suspend fun getFromApi(
@@ -21,27 +16,5 @@ interface WeatherApi {
@Query("units") units: String = "metric" @Query("units") units: String = "metric"
): Response<WeatherResponse> ): Response<WeatherResponse>
// invoke method creating an invocation of the api call
companion object{
operator fun invoke(
baseUrl: String,
networkConnectionInterceptor: NetworkConnectionInterceptor,
queryParamsInterceptor: QueryParamsInterceptor,
loggingInterceptor: HttpLoggingInterceptor
) : WeatherApi {
val okHttpClient = buildOkHttpClient(
networkConnectionInterceptor,
queryParamsInterceptor,
loggingInterceptor
)
return createRetrofit(
baseUrl,
okHttpClient,
WeatherApi::class.java
)
}
}
} }

View File

@@ -7,7 +7,7 @@ import java.io.IOException
class NetworkConnectionInterceptor( class NetworkConnectionInterceptor(
context: Context context: Context
) : Interceptor { ) : NetworkInterceptor {
private val applicationContext = context.applicationContext private val applicationContext = context.applicationContext

View File

@@ -0,0 +1,5 @@
package com.appttude.h_mal.atlas_weather.data.network.interceptors
import okhttp3.Interceptor
interface NetworkInterceptor : Interceptor

View File

@@ -14,14 +14,13 @@ class QueryParamsInterceptor : Interceptor{
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request() val original = chain.request()
val originalHttpUrl = original.url
val url = originalHttpUrl.newBuilder() val url = original.url.newBuilder()
.addQueryParameter("appid", id) .addQueryParameter("appid", id)
.build() .build()
// Request customization: add request headers // Request customization: add request headers
val requestBuilder= original.newBuilder().url(url) val requestBuilder = original.newBuilder().url(url)
val request: Request = requestBuilder.build() val request: Request = requestBuilder.build()
return chain.proceed(request) return chain.proceed(request)

View File

@@ -1,6 +1,7 @@
package com.appttude.h_mal.atlas_weather.data.network.networkUtils package com.appttude.h_mal.atlas_weather.data.network.networkUtils
import com.appttude.h_mal.atlas_weather.data.network.interceptors.NetworkConnectionInterceptor import com.appttude.h_mal.atlas_weather.data.network.interceptors.NetworkConnectionInterceptor
import com.appttude.h_mal.atlas_weather.data.network.interceptors.NetworkInterceptor
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
@@ -13,7 +14,6 @@ val loggingInterceptor = HttpLoggingInterceptor().apply {
} }
fun buildOkHttpClient( fun buildOkHttpClient(
networkConnectionInterceptor: NetworkConnectionInterceptor,
vararg interceptor: Interceptor, vararg interceptor: Interceptor,
timeoutSeconds: Long = 30L timeoutSeconds: Long = 30L
): OkHttpClient { ): OkHttpClient {
@@ -21,11 +21,14 @@ fun buildOkHttpClient(
val builder = OkHttpClient.Builder() val builder = OkHttpClient.Builder()
interceptor.forEach { interceptor.forEach {
builder.addInterceptor(it) if (it is NetworkInterceptor) {
builder.addNetworkInterceptor(it)
} else {
builder.addInterceptor(it)
}
} }
builder.addNetworkInterceptor(networkConnectionInterceptor) builder.connectTimeout(timeoutSeconds, TimeUnit.SECONDS)
.connectTimeout(timeoutSeconds, TimeUnit.SECONDS)
.writeTimeout(timeoutSeconds, TimeUnit.SECONDS) .writeTimeout(timeoutSeconds, TimeUnit.SECONDS)
.readTimeout(timeoutSeconds, TimeUnit.SECONDS) .readTimeout(timeoutSeconds, TimeUnit.SECONDS)

View File

@@ -7,9 +7,9 @@ import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.utils.FALLBACK_TIME
private const val FIVE_MINS = 300000L
class RepositoryImpl( class RepositoryImpl(
private val api: WeatherApi, private val api: WeatherApi,
private val db: AppDatabase, private val db: AppDatabase,
@@ -53,7 +53,7 @@ class RepositoryImpl(
?: return true ?: return true
val difference = System.currentTimeMillis() - lastSaved val difference = System.currentTimeMillis() - lastSaved
return difference > FIVE_MINS return difference > FALLBACK_TIME
} }
override fun saveLastSavedAt(locationName: String) { override fun saveLastSavedAt(locationName: String) {

View File

@@ -0,0 +1,3 @@
package com.appttude.h_mal.atlas_weather.utils
val FALLBACK_TIME: Long = 300000L

View File

@@ -0,0 +1,4 @@
{
"cod": 401,
"message": "Invalid API key. Please see http://openweathermap.org/faq#error401 for more info."
}

View File

@@ -0,0 +1,349 @@
{
"lat": 51.51,
"lon": -0.13,
"timezone": "Europe/London",
"timezone_offset": 3600,
"current": {
"dt": 1648932980,
"sunrise": 1648877626,
"sunset": 1648924449,
"temp": 2.14,
"feels_like": 0.13,
"pressure": 1025,
"humidity": 79,
"dew_point": -0.99,
"uvi": 0,
"clouds": 72,
"visibility": 10000,
"wind_speed": 1.92,
"wind_deg": 69,
"wind_gust": 4.81,
"weather": [
{
"id": 803,
"main": "Clouds",
"description": "broken clouds",
"icon": "04n"
}
]
},
"daily": [
{
"dt": 1648900800,
"sunrise": 1648877626,
"sunset": 1648924449,
"moonrise": 1648880220,
"moonset": 1648930140,
"moon_phase": 0.04,
"temp": {
"day": 8.5,
"min": 1,
"max": 8.97,
"night": 2.43,
"eve": 4.76,
"morn": 1
},
"feels_like": {
"day": 6.21,
"night": 0.54,
"eve": 2.22,
"morn": -1.74
},
"pressure": 1022,
"humidity": 35,
"dew_point": -6.02,
"wind_speed": 4.07,
"wind_deg": 41,
"wind_gust": 7.58,
"weather": [
{
"id": 804,
"main": "Clouds",
"description": "overcast clouds",
"icon": "04d"
}
],
"clouds": 100,
"pop": 0.27,
"uvi": 3.03
},
{
"dt": 1648987200,
"sunrise": 1648963890,
"sunset": 1649010949,
"moonrise": 1648967460,
"moonset": 1649020980,
"moon_phase": 0.07,
"temp": {
"day": 8.92,
"min": 0.99,
"max": 9.47,
"night": 5.62,
"eve": 8.19,
"morn": 1.01
},
"feels_like": {
"day": 7.63,
"night": 2.63,
"eve": 6.61,
"morn": -0.71
},
"pressure": 1026,
"humidity": 38,
"dew_point": -4.76,
"wind_speed": 3.99,
"wind_deg": 250,
"wind_gust": 9.75,
"weather": [
{
"id": 802,
"main": "Clouds",
"description": "scattered clouds",
"icon": "03d"
}
],
"clouds": 30,
"pop": 0,
"uvi": 3.04
},
{
"dt": 1649073600,
"sunrise": 1649050154,
"sunset": 1649097449,
"moonrise": 1649054880,
"moonset": 1649111880,
"moon_phase": 0.1,
"temp": {
"day": 9.43,
"min": 4.54,
"max": 12.17,
"night": 10.64,
"eve": 11.38,
"morn": 4.91
},
"feels_like": {
"day": 6.79,
"night": 10.01,
"eve": 10.87,
"morn": 0.46
},
"pressure": 1011,
"humidity": 93,
"dew_point": 8.41,
"wind_speed": 6.87,
"wind_deg": 245,
"wind_gust": 15.86,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 100,
"pop": 0.92,
"rain": 1.95,
"uvi": 1.07
},
{
"dt": 1649160000,
"sunrise": 1649136419,
"sunset": 1649183949,
"moonrise": 1649142480,
"moonset": 0,
"moon_phase": 0.13,
"temp": {
"day": 13.7,
"min": 9.71,
"max": 14.06,
"night": 9.71,
"eve": 11.64,
"morn": 10.05
},
"feels_like": {
"day": 12.85,
"night": 6.63,
"eve": 10.66,
"morn": 9.25
},
"pressure": 1009,
"humidity": 66,
"dew_point": 7.47,
"wind_speed": 6.8,
"wind_deg": 258,
"wind_gust": 13,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 99,
"pop": 0.36,
"rain": 0.13,
"uvi": 2.59
},
{
"dt": 1649246400,
"sunrise": 1649222684,
"sunset": 1649270449,
"moonrise": 1649230500,
"moonset": 1649202540,
"moon_phase": 0.17,
"temp": {
"day": 12.97,
"min": 8.53,
"max": 13.85,
"night": 8.53,
"eve": 9.68,
"morn": 9.51
},
"feels_like": {
"day": 11.94,
"night": 4.36,
"eve": 6.3,
"morn": 6.57
},
"pressure": 996,
"humidity": 62,
"dew_point": 5.84,
"wind_speed": 9.57,
"wind_deg": 260,
"wind_gust": 18.43,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 100,
"pop": 0.86,
"rain": 1.95,
"uvi": 3.29
},
{
"dt": 1649332800,
"sunrise": 1649308949,
"sunset": 1649356949,
"moonrise": 1649319000,
"moonset": 1649292960,
"moon_phase": 0.2,
"temp": {
"day": 9.25,
"min": 4.63,
"max": 10.29,
"night": 7.17,
"eve": 8.81,
"morn": 4.63
},
"feels_like": {
"day": 6.57,
"night": 5.9,
"eve": 7.06,
"morn": -0.26
},
"pressure": 1000,
"humidity": 33,
"dew_point": -6.33,
"wind_speed": 9.1,
"wind_deg": 258,
"wind_gust": 21.32,
"weather": [
{
"id": 803,
"main": "Clouds",
"description": "broken clouds",
"icon": "04d"
}
],
"clouds": 66,
"pop": 0,
"uvi": 3.59
},
{
"dt": 1649419200,
"sunrise": 1649395215,
"sunset": 1649443449,
"moonrise": 1649408100,
"moonset": 1649382900,
"moon_phase": 0.23,
"temp": {
"day": 8,
"min": 3.94,
"max": 8.91,
"night": 4.79,
"eve": 7.09,
"morn": 3.94
},
"feels_like": {
"day": 6.02,
"night": 1.81,
"eve": 4.59,
"morn": 2.23
},
"pressure": 1000,
"humidity": 39,
"dew_point": -5.12,
"wind_speed": 4.52,
"wind_deg": 354,
"wind_gust": 9.29,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 100,
"pop": 0.8,
"rain": 1.51,
"uvi": 4
},
{
"dt": 1649505600,
"sunrise": 1649481482,
"sunset": 1649529949,
"moonrise": 1649497920,
"moonset": 1649472180,
"moon_phase": 0.25,
"temp": {
"day": 8.7,
"min": 1.91,
"max": 9.35,
"night": 5.29,
"eve": 7.66,
"morn": 1.91
},
"feels_like": {
"day": 5.76,
"night": 2.55,
"eve": 4.78,
"morn": -1.89
},
"pressure": 1016,
"humidity": 36,
"dew_point": -5.69,
"wind_speed": 5.65,
"wind_deg": 314,
"wind_gust": 11.58,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 55,
"pop": 0.3,
"rain": 0.22,
"uvi": 4
}
]
}